mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
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:
parent
6d152a5ccf
commit
d59adfd10c
|
|
@ -357,6 +357,12 @@ export default {
|
||||||
'apitoken.scope_read': 'Nur Lesen',
|
'apitoken.scope_read': 'Nur Lesen',
|
||||||
'apitoken.scope_write': 'Lesen & Schreiben',
|
'apitoken.scope_write': 'Lesen & Schreiben',
|
||||||
'apitoken.scope_full': 'Voll (inkl. Gerätebefehle)',
|
'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.create': 'Token erstellen',
|
||||||
'apitoken.none': 'Noch keine Tokens.',
|
'apitoken.none': 'Noch keine Tokens.',
|
||||||
'apitoken.col_token': 'Token',
|
'apitoken.col_token': 'Token',
|
||||||
|
|
|
||||||
|
|
@ -393,6 +393,12 @@ export default {
|
||||||
'apitoken.scope_read': 'Read only',
|
'apitoken.scope_read': 'Read only',
|
||||||
'apitoken.scope_write': 'Read & write',
|
'apitoken.scope_write': 'Read & write',
|
||||||
'apitoken.scope_full': 'Full (incl. device commands)',
|
'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.create': 'Create token',
|
||||||
'apitoken.none': 'No tokens yet.',
|
'apitoken.none': 'No tokens yet.',
|
||||||
'apitoken.col_token': 'Token',
|
'apitoken.col_token': 'Token',
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,12 @@ export default {
|
||||||
'apitoken.scope_read': 'Solo lectura',
|
'apitoken.scope_read': 'Solo lectura',
|
||||||
'apitoken.scope_write': 'Lectura y escritura',
|
'apitoken.scope_write': 'Lectura y escritura',
|
||||||
'apitoken.scope_full': 'Completo (incl. comandos de dispositivo)',
|
'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.create': 'Crear token',
|
||||||
'apitoken.none': 'Aún no hay tokens.',
|
'apitoken.none': 'Aún no hay tokens.',
|
||||||
'apitoken.col_token': 'Token',
|
'apitoken.col_token': 'Token',
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,12 @@ export default {
|
||||||
'apitoken.scope_read': 'Lecture seule',
|
'apitoken.scope_read': 'Lecture seule',
|
||||||
'apitoken.scope_write': 'Lecture et écriture',
|
'apitoken.scope_write': 'Lecture et écriture',
|
||||||
'apitoken.scope_full': 'Complet (cmd. appareils incluses)',
|
'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.create': 'Créer un jeton',
|
||||||
'apitoken.none': 'Aucun jeton pour le moment.',
|
'apitoken.none': 'Aucun jeton pour le moment.',
|
||||||
'apitoken.col_token': 'Jeton',
|
'apitoken.col_token': 'Jeton',
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,12 @@ export default {
|
||||||
'apitoken.scope_read': 'Somente leitura',
|
'apitoken.scope_read': 'Somente leitura',
|
||||||
'apitoken.scope_write': 'Leitura e escrita',
|
'apitoken.scope_write': 'Leitura e escrita',
|
||||||
'apitoken.scope_full': 'Completo (incl. comandos de dispositivo)',
|
'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.create': 'Criar token',
|
||||||
'apitoken.none': 'Ainda não há tokens.',
|
'apitoken.none': 'Ainda não há tokens.',
|
||||||
'apitoken.col_token': 'Token',
|
'apitoken.col_token': 'Token',
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,16 @@ export async function render(container) {
|
||||||
<option value="read">${esc(t('apitoken.scope_read'))}</option>
|
<option value="read">${esc(t('apitoken.scope_read'))}</option>
|
||||||
<option value="write">${esc(t('apitoken.scope_write'))}</option>
|
<option value="write">${esc(t('apitoken.scope_write'))}</option>
|
||||||
<option value="full">${esc(t('apitoken.scope_full'))}</option>
|
<option value="full">${esc(t('apitoken.scope_full'))}</option>
|
||||||
|
<option value="agency">${esc(t('apitoken.scope_agency'))}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" id="createTokenBtn">${t('apitoken.create')}</button>
|
<button class="btn btn-primary btn-sm" id="createTokenBtn">${t('apitoken.create')}</button>
|
||||||
</div>
|
</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="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>
|
</div>
|
||||||
|
|
@ -329,6 +335,7 @@ export async function render(container) {
|
||||||
read: t('apitoken.scope_read'),
|
read: t('apitoken.scope_read'),
|
||||||
write: t('apitoken.scope_write'),
|
write: t('apitoken.scope_write'),
|
||||||
full: t('apitoken.scope_full'),
|
full: t('apitoken.scope_full'),
|
||||||
|
agency: t('apitoken.scope_agency'),
|
||||||
}[s] || s);
|
}[s] || s);
|
||||||
|
|
||||||
async function loadTokens() {
|
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' : ''}">
|
<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)}…</td>
|
<td style="padding:10px 12px;font-family:monospace">${esc(tok.prefix)}…</td>
|
||||||
<td style="padding:10px 12px">${esc(tok.name || '')}</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">${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">${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">
|
<td style="padding:10px 12px;white-space:nowrap;text-align:right">
|
||||||
|
|
@ -388,13 +398,36 @@ export async function render(container) {
|
||||||
|
|
||||||
loadTokens();
|
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 () => {
|
document.getElementById('createTokenBtn')?.addEventListener('click', async () => {
|
||||||
const name = document.getElementById('tokName').value.trim();
|
const name = document.getElementById('tokName').value.trim();
|
||||||
const scope = document.getElementById('tokScope').value;
|
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');
|
const btn = document.getElementById('createTokenBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const r = await api.createToken({ name, scope });
|
const r = await api.createToken(payload);
|
||||||
const box = document.getElementById('tokenSecretBox');
|
const box = document.getElementById('tokenSecretBox');
|
||||||
box.style.display = 'block';
|
box.style.display = 'block';
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ router.get('/', (req, res) => {
|
||||||
SELECT id, prefix, name, scope, workspace_id, created_at, last_used_at, revoked_at
|
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
|
FROM api_tokens WHERE user_id = ? AND workspace_id = ? ORDER BY created_at DESC
|
||||||
`).all(req.user.id, req.workspaceId);
|
`).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);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue