feat(ui): edit-designations for agency tokens (#73)

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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-14 17:04:07 -05:00
parent 6ea8100aeb
commit 4c38536cc6
8 changed files with 68 additions and 1 deletions

View file

@ -160,6 +160,7 @@ export const api = {
getTokens: () => request('/tokens'), getTokens: () => request('/tokens'),
createToken: (data) => request('/tokens', { method: 'POST', body: JSON.stringify(data) }), createToken: (data) => request('/tokens', { method: 'POST', body: JSON.stringify(data) }),
revokeToken: (id) => request('/tokens/' + id, { method: 'DELETE' }), 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 // Current user
getMe: () => request('/auth/me'), getMe: () => request('/auth/me'),

View file

@ -363,6 +363,8 @@ export default {
'apitoken.agency_needs_playlists': 'Wähle mindestens eine Playlist für einen Agentur-Token.', '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.agency_no_playlists': 'Erstelle zuerst eine Playlist ein Agentur-Token muss auf eine zielen.',
'apitoken.targets_label': 'Zugewiesen:', '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_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_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', 'apitoken.auto_publish_on': 'Auto-Veröffentlichung an',

View file

@ -399,6 +399,8 @@ export default {
'apitoken.agency_needs_playlists': 'Select at least one playlist for an agency token.', '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.agency_no_playlists': 'Create a playlist first — an agency token must target one.',
'apitoken.targets_label': 'Designated:', '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_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_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', 'apitoken.auto_publish_on': 'auto-publish on',

View file

@ -362,6 +362,8 @@ export default {
'apitoken.agency_needs_playlists': 'Selecciona al menos una lista para un token de agencia.', '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.agency_no_playlists': 'Crea una lista primero: un token de agencia debe apuntar a una.',
'apitoken.targets_label': 'Designadas:', '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_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_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', 'apitoken.auto_publish_on': 'publicación automática activada',

View file

@ -363,6 +363,8 @@ export default {
'apitoken.agency_needs_playlists': 'Sélectionnez au moins une liste pour un jeton d\'agence.', '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.agency_no_playlists': 'Créez d\'abord une liste : un jeton d\'agence doit en cibler une.',
'apitoken.targets_label': 'Assignées :', '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_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_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', 'apitoken.auto_publish_on': 'publication automatique activée',

View file

@ -363,6 +363,8 @@ export default {
'apitoken.agency_needs_playlists': 'Selecione pelo menos uma lista para um token de agência.', '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.agency_no_playlists': 'Crie uma lista primeiro: um token de agência deve apontar para uma.',
'apitoken.targets_label': 'Designadas:', '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_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_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', 'apitoken.auto_publish_on': 'publicação automática ativada',

View file

@ -90,6 +90,7 @@ export async function render(container) {
</div> </div>
<div id="tokenSecretBox" style="display:none"></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 id="tokenList"><p style="color:var(--text-muted);font-size:13px">${t('settings.loading_users')}</p></div>
<div id="tokenEditPanel" style="display:none"></div>
</div> </div>
${isAdmin ? ` ${isAdmin ? `
@ -377,7 +378,7 @@ export async function render(container) {
<td style="padding:10px 12px;white-space:nowrap;text-align:right"> <td style="padding:10px 12px;white-space:nowrap;text-align:right">
${tok.revoked_at ${tok.revoked_at
? `<span style="color:var(--text-muted);font-size:12px">${t('apitoken.revoked')}</span>` ? `<span style="color:var(--text-muted);font-size:12px">${t('apitoken.revoked')}</span>`
: `<button class="btn btn-secondary btn-sm revoke-token-btn" data-id="${esc(String(tok.id))}">${t('apitoken.revoke')}</button>`} : `${tok.scope === 'agency' ? `<button class="btn btn-secondary btn-sm edit-targets-btn" data-id="${esc(String(tok.id))}" data-targets="${esc((tok.targets || []).map(p => p.id).join(','))}">${t('apitoken.edit_targets')}</button> ` : ''}<button class="btn btn-secondary btn-sm revoke-token-btn" data-id="${esc(String(tok.id))}">${t('apitoken.revoke')}</button>`}
</td> </td>
</tr> </tr>
`).join('')} `).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 = `
<div style="border:1px solid var(--accent);border-radius:var(--radius);padding:16px;margin-top:12px">
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.edit_targets')}</h4>
<div style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto;margin-bottom:12px">
${pls.length
? pls.map(p => `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="edit-pl" value="${esc(String(p.id))}"${current.has(String(p.id)) ? ' checked' : ''}> ${esc(p.name)}</label>`).join('')
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`}
</div>
<button class="btn btn-primary btn-sm" id="saveTargetsBtn">${t('common.save')}</button>
<button class="btn btn-secondary btn-sm" id="cancelTargetsBtn">${t('common.cancel')}</button>
</div>`;
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(); loadTokens();

View file

@ -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(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'); 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');
});