From 4c38536cc69c43cc0e8c0c3884dc07c145dda9a3 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 17:04:07 -0500 Subject: [PATCH] feat(ui): edit-designations for agency tokens (#73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → API Tokens: each agency token gets an "Edit playlists" control that opens the playlist picker pre-checked with the token's CURRENT designations (from the list GET's tok.targets), lets the admin add/remove, and calls the existing PUT /:id/targets to atomically re-designate. Reuses the creation picker pattern; common.save/cancel reused; edit_targets + targets_updated i18n across all 5 locales. No security-model change - the endpoint was already proven. Test (integration): PUT /:id/targets re-designates (add + remove) and the confinement follows the NEW set - a re-designated token reaches only its new playlists (router.param 403s the removed one). 148 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/api.js | 1 + frontend/js/i18n/de.js | 2 ++ frontend/js/i18n/en.js | 2 ++ frontend/js/i18n/es.js | 2 ++ frontend/js/i18n/fr.js | 2 ++ frontend/js/i18n/pt.js | 2 ++ frontend/js/views/settings.js | 34 +++++++++++++++++++++++++++++++++- server/test/agency.test.js | 24 ++++++++++++++++++++++++ 8 files changed, 68 insertions(+), 1 deletion(-) diff --git a/frontend/js/api.js b/frontend/js/api.js index e0ec520..790bc9e 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -160,6 +160,7 @@ export const api = { getTokens: () => request('/tokens'), createToken: (data) => request('/tokens', { method: 'POST', body: JSON.stringify(data) }), revokeToken: (id) => request('/tokens/' + id, { method: 'DELETE' }), + setTokenTargets: (id, target_playlist_ids) => request('/tokens/' + id + '/targets', { method: 'PUT', body: JSON.stringify({ target_playlist_ids }) }), // #73: re-designate agency token playlists // Current user getMe: () => request('/auth/me'), diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 36f6bb1..a12fa31 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -363,6 +363,8 @@ export default { '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.edit_targets': 'Playlists bearbeiten', + 'apitoken.targets_updated': 'Zuweisungen aktualisiert', 'apitoken.auto_publish_label': 'Automatisch veröffentlichen (meine Freigabe überspringen)', 'apitoken.auto_publish_hint': 'Aus (Standard): Hinzufügungen warten als Entwurf auf deine Veröffentlichung. An: sie gehen sofort live – nur für Agenturen, denen du voll vertraust.', 'apitoken.auto_publish_on': 'Auto-Veröffentlichung an', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 4754cff..624ef0b 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -399,6 +399,8 @@ export default { '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.edit_targets': 'Edit playlists', + 'apitoken.targets_updated': 'Designations updated', 'apitoken.auto_publish_label': 'Auto-publish (skip my approval)', 'apitoken.auto_publish_hint': 'Off (default): additions wait as drafts for you to publish. On: they go live immediately — only for agencies you fully trust.', 'apitoken.auto_publish_on': 'auto-publish on', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index d4080b7..53e5696 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -362,6 +362,8 @@ export default { '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.edit_targets': 'Editar listas', + 'apitoken.targets_updated': 'Designaciones actualizadas', 'apitoken.auto_publish_label': 'Publicación automática (omitir mi aprobación)', 'apitoken.auto_publish_hint': 'Desactivado (predeterminado): las adiciones esperan como borradores para que las publiques. Activado: se publican de inmediato, solo para agencias de plena confianza.', 'apitoken.auto_publish_on': 'publicación automática activada', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index bec332b..ff2d2bf 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -363,6 +363,8 @@ export default { '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.edit_targets': 'Modifier les listes', + 'apitoken.targets_updated': 'Désignations mises à jour', 'apitoken.auto_publish_label': 'Publication automatique (ignorer mon approbation)', 'apitoken.auto_publish_hint': 'Désactivé (par défaut) : les ajouts attendent en brouillon votre publication. Activé : ils sont diffusés immédiatement, uniquement pour les agences de pleine confiance.', 'apitoken.auto_publish_on': 'publication automatique activée', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index ffa7553..6708436 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -363,6 +363,8 @@ export default { '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.edit_targets': 'Editar listas', + 'apitoken.targets_updated': 'Designações atualizadas', 'apitoken.auto_publish_label': 'Publicação automática (ignorar minha aprovação)', 'apitoken.auto_publish_hint': 'Desativado (padrão): as adições aguardam como rascunho para você publicar. Ativado: vão ao ar imediatamente, apenas para agências de total confiança.', 'apitoken.auto_publish_on': 'publicação automática ativada', diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index dcec920..5c8f93a 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -90,6 +90,7 @@ export async function render(container) {

${t('settings.loading_users')}

+ ${isAdmin ? ` @@ -377,7 +378,7 @@ export async function render(container) { ${tok.revoked_at ? `${t('apitoken.revoked')}` - : ``} + : `${tok.scope === 'agency' ? ` ` : ''}`} `).join('')} @@ -398,6 +399,37 @@ export async function render(container) { } }); }); + + // #73: edit an agency token's playlist designations -> PUT /:id/targets (atomic re-designate). + el.querySelectorAll('.edit-targets-btn').forEach(btn => btn.addEventListener('click', async () => { + const id = btn.dataset.id; + const current = new Set((btn.dataset.targets || '').split(',').filter(Boolean)); + const panel = document.getElementById('tokenEditPanel'); + const pls = await api.getPlaylists().catch(() => []); + panel.style.display = 'block'; + panel.innerHTML = ` +
+

${t('apitoken.edit_targets')}

+
+ ${pls.length + ? pls.map(p => ``).join('') + : `

${t('apitoken.agency_no_playlists')}

`} +
+ + +
`; + document.getElementById('saveTargetsBtn').onclick = async () => { + const ids = [...panel.querySelectorAll('.edit-pl:checked')].map(c => c.value); + if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error'); + try { + await api.setTokenTargets(id, ids); + showToast(t('apitoken.targets_updated'), 'success'); + panel.style.display = 'none'; + loadTokens(); + } catch (err) { showToast(err.message, 'error'); } + }; + document.getElementById('cancelTargetsBtn').onclick = () => { panel.style.display = 'none'; }; + })); } loadTokens(); diff --git a/server/test/agency.test.js b/server/test/agency.test.js index ca857f9..ce552ad 100644 --- a/server/test/agency.test.js +++ b/server/test/agency.test.js @@ -135,3 +135,27 @@ test('#73 auto-publish: the TOKEN flag decides draft vs live; the body can never assert.equal(pub.status, 200, 'manual publish works post-extraction'); assert.equal((await jfetch(`/api/playlists/${plD.id}`, jwtAuth(jwt))).body.status, 'published', 'manual publish sets status=published'); }); + +test('#73 edit-designations: PUT /:id/targets re-designates (add + remove); confinement follows', async () => { + const auth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok } }); + const email = 're' + crypto.randomBytes(4).toString('hex') + '@x.local'; + const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token; + const plA = (await jfetch('/api/playlists', jpost(jwt, { name: 'A' }))).body; + const plB = (await jfetch('/api/playlists', jpost(jwt, { name: 'B' }))).body; + const plC = (await jfetch('/api/playlists', jpost(jwt, { name: 'C' }))).body; + + const tokRes = await jfetch('/api/tokens', jpost(jwt, { name: 'EditMe', scope: 'agency', target_playlist_ids: [plA.id, plB.id] })); + const atok = tokRes.body.token, tokId = tokRes.body.id; + // initially A+B designated (200 = router.param lets it through), C not (403) + assert.equal((await jfetch(`/api/agency/playlists/${plA.id}/layout`, auth(atok))).status, 200, 'A reachable'); + assert.equal((await jfetch(`/api/agency/playlists/${plC.id}/layout`, auth(atok))).status, 403, 'C not yet designated'); + + // re-designate: drop A, keep B, add C + const put = await jfetch(`/api/tokens/${tokId}/targets`, { method: 'PUT', headers: { Authorization: 'Bearer ' + jwt, 'Content-Type': 'application/json' }, body: JSON.stringify({ target_playlist_ids: [plB.id, plC.id] }) }); + assert.equal(put.status, 200, 're-designate ok'); + + // confinement follows the NEW set: removed A -> 403, kept B -> 200, added C -> 200 + assert.equal((await jfetch(`/api/agency/playlists/${plA.id}/layout`, auth(atok))).status, 403, 'removed A -> 403'); + assert.equal((await jfetch(`/api/agency/playlists/${plB.id}/layout`, auth(atok))).status, 200, 'kept B -> 200'); + assert.equal((await jfetch(`/api/agency/playlists/${plC.id}/layout`, auth(atok))).status, 200, 'added C -> 200'); +});