mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
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:
parent
6ea8100aeb
commit
4c38536cc6
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export async function render(container) {
|
|||
</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="tokenEditPanel" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
${isAdmin ? `
|
||||
|
|
@ -377,7 +378,7 @@ export async function render(container) {
|
|||
<td style="padding:10px 12px;white-space:nowrap;text-align:right">
|
||||
${tok.revoked_at
|
||||
? `<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>
|
||||
</tr>
|
||||
`).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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue