diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index c299d14f..7224abdd 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -53,7 +53,7 @@ SPLIT_MODULES = [ # Other JS files that exist in static/ but are NOT part of the split NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "worker-orbs.js", "enrichment-manager.js", "origin-history.js", "blocklist.js", - "watchlist-history.js"} + "watchlist-history.js", "credential-sets.js"} # Pre-existing duplicate helper functions that lived in the original monolith. # In a plain + diff --git a/webui/static/credential-sets.js b/webui/static/credential-sets.js new file mode 100644 index 00000000..deb09e9c --- /dev/null +++ b/webui/static/credential-sets.js @@ -0,0 +1,230 @@ +/* + * Service credential sets — admin manager (Settings) + per-profile quick-switch + * modal (sidebar Service Status). + * + * Admin creates named credential "pills" per auth service; every profile picks + * which one is active for it. Secrets are entered here but NEVER read back — + * the API only ever returns id/label, so this UI shows names, never values. + * + * Backend: /api/credentials (admin CRUD) and /api/profiles/me/services[/select] + * (per-profile selection, any profile). + */ + +// Display order + labels for the supported services (mirrors the backend +// SERVICE_CREDENTIAL_SCHEMA). Each lists the fields the admin enters; `req` +// marks required (matches server validation), `pw` renders as a password input. +const CRED_SERVICES = [ + { id: 'spotify', name: 'Spotify', fields: [ + { key: 'client_id', label: 'Client ID', req: true }, + { key: 'client_secret', label: 'Client Secret', req: true, pw: true }, + { key: 'redirect_uri', label: 'Redirect URI (optional)' }, + ]}, + { id: 'tidal', name: 'Tidal', fields: [ + { key: 'access_token', label: 'Access Token', req: true, pw: true }, + { key: 'refresh_token', label: 'Refresh Token', req: true, pw: true }, + ]}, + { id: 'deezer', name: 'Deezer', fields: [ + { key: 'arl', label: 'ARL', req: true, pw: true }, + ]}, + { id: 'qobuz', name: 'Qobuz', fields: [ + { key: 'user_auth_token', label: 'User Auth Token', req: true, pw: true }, + ]}, + { id: 'plex', name: 'Plex', fields: [ + { key: 'base_url', label: 'Server URL', req: true }, + { key: 'token', label: 'Token', req: true, pw: true }, + ]}, + { id: 'jellyfin', name: 'Jellyfin', fields: [ + { key: 'base_url', label: 'Server URL', req: true }, + { key: 'api_key', label: 'API Key', req: true, pw: true }, + ]}, + { id: 'navidrome', name: 'Navidrome', fields: [ + { key: 'base_url', label: 'Server URL', req: true }, + { key: 'username', label: 'Username', req: true }, + { key: 'password', label: 'Password', req: true, pw: true }, + ]}, +]; + +const _credServiceById = Object.fromEntries(CRED_SERVICES.map(s => [s.id, s])); + +function _credEsc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +// ── Admin manager (rendered inside Settings) ───────────────────────────────── + +async function loadCredentialSets() { + const host = document.getElementById('credential-sets-container'); + if (!host) return; + try { + const res = await fetch('/api/credentials'); + if (res.status === 403) { host.innerHTML = ''; return; } // not admin + const data = await res.json(); + _renderCredentialSets(host, (data && data.services) || {}); + } catch (e) { + host.innerHTML = '
Could not load credential sets.
'; + } +} + +function _renderCredentialSets(host, services) { + host.innerHTML = CRED_SERVICES.map(svc => { + const sets = services[svc.id] || []; + const pills = sets.map(s => ` + + ${_credEsc(s.label)} + + `).join(''); + return ` +
+
+ ${svc.name} + +
+
${pills || 'No saved accounts'}
+ +
`; + }).join(''); +} + +function toggleAddCredentialForm(serviceId) { + const form = document.getElementById(`cred-add-form-${serviceId}`); + if (!form) return; + if (!form.hidden) { form.hidden = true; form.innerHTML = ''; return; } + const svc = _credServiceById[serviceId]; + form.innerHTML = ` + + ${svc.fields.map(f => ` + `).join('')} +
+ + +
+
`; + form.hidden = false; + const first = document.getElementById(`cred-new-label-${serviceId}`); + if (first) first.focus(); +} + +async function saveNewCredential(serviceId) { + const svc = _credServiceById[serviceId]; + const errEl = document.getElementById(`cred-form-error-${serviceId}`); + const label = (document.getElementById(`cred-new-label-${serviceId}`).value || '').trim(); + if (!label) { if (errEl) errEl.textContent = 'Give this account a name.'; return; } + const payload = {}; + for (const f of svc.fields) { + const v = (document.getElementById(`cred-new-${serviceId}-${f.key}`).value || '').trim(); + if (v) payload[f.key] = v; + } + const missing = svc.fields.filter(f => f.req && !payload[f.key]).map(f => f.label); + if (missing.length) { if (errEl) errEl.textContent = `Required: ${missing.join(', ')}`; return; } + + try { + const res = await fetch('/api/credentials', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service: serviceId, label, payload }), + }); + const data = await res.json(); + if (!data.success) { if (errEl) errEl.textContent = data.error || 'Save failed.'; return; } + if (typeof showToast === 'function') showToast(`Saved "${label}" for ${svc.name}`, 'success'); + loadCredentialSets(); + } catch (e) { + if (errEl) errEl.textContent = 'Save failed.'; + } +} + +async function deleteCredentialSet(id, serviceName) { + if (!confirm(`Delete this ${serviceName} account? Profiles using it will fall back to the default.`)) return; + try { + const res = await fetch(`/api/credentials/${id}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + if (typeof showToast === 'function') showToast('Account deleted', 'success'); + loadCredentialSets(); + } else if (typeof showToast === 'function') { + showToast(data.error || 'Delete failed', 'error'); + } + } catch (e) { /* no-op */ } +} + +// ── Quick-switch modal (sidebar Service Status → switch active account) ────── + +function openServiceSwitchModal() { + let overlay = document.getElementById('service-switch-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'service-switch-overlay'; + overlay.className = 'modal-overlay cred-switch-overlay'; + overlay.innerHTML = ` +
+
+
+

Quick Switch

+

Choose which account each service uses for your profile. Set up by the admin.

+
+ +
+
+
`; + overlay.addEventListener('click', (e) => { if (e.target === overlay) closeServiceSwitchModal(); }); + document.body.appendChild(overlay); + } + overlay.classList.remove('hidden'); + _loadServiceSwitch(); +} + +function closeServiceSwitchModal() { + const o = document.getElementById('service-switch-overlay'); + if (o) o.classList.add('hidden'); +} + +async function _loadServiceSwitch() { + const body = document.getElementById('cred-switch-body'); + if (body) body.innerHTML = '
Loading…
'; + try { + const res = await fetch('/api/profiles/me/services'); + const data = await res.json(); + _renderServiceSwitch(body, (data && data.services) || {}); + } catch (e) { + if (body) body.innerHTML = '
Could not load.
'; + } +} + +function _renderServiceSwitch(body, services) { + // Only show services the admin has actually set up alternate accounts for. + const rows = CRED_SERVICES.filter(svc => (services[svc.id]?.options || []).length).map(svc => { + const info = services[svc.id]; + const selected = info.selected_id; + const pills = [ + ``, + ...info.options.map(o => ` + `), + ].join(''); + return ` +
+
${svc.name}
+
${pills}
+
`; + }).join(''); + body.innerHTML = rows || + '
No alternate accounts have been set up by the admin yet.
'; +} + +async function selectServiceCredential(service, credentialId) { + try { + const res = await fetch('/api/profiles/me/services/select', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service, credential_id: credentialId }), + }); + const data = await res.json(); + if (data.success) { + _loadServiceSwitch(); // re-render with the new active pill + } else if (typeof showToast === 'function') { + showToast(data.error || 'Switch failed', 'error'); + } + } catch (e) { /* no-op */ } +} diff --git a/webui/static/settings.js b/webui/static/settings.js index 7c82f392..9cc9e341 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -1437,6 +1437,9 @@ async function loadSettingsData() { // sentinel, which the server treats as "keep existing"). _wireRedactedSecrets(); + // Admin "Connected Accounts" manager (no-op / empty for non-admins). + if (typeof loadCredentialSets === 'function') loadCredentialSets(); + } catch (error) { console.error('Error loading settings:', error); showToast('Failed to load settings', 'error'); diff --git a/webui/static/style.css b/webui/static/style.css index 03987c26..32c7cf4f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -67521,3 +67521,94 @@ body.em-scroll-lock { overflow: hidden; } .blocklist-empty { padding: 26px 16px; text-align: center; color: rgba(255,255,255,0.4); font-size: 12.5px; } .blocklist-search-results::-webkit-scrollbar, .blocklist-current::-webkit-scrollbar { width: 8px; } .blocklist-search-results::-webkit-scrollbar-thumb, .blocklist-current::-webkit-scrollbar-thumb { background: rgba(var(--accent-rgb),0.28); border-radius: 999px; } + +/* ── Connected Accounts (admin credential-set manager) + quick-switch modal ── */ +.credential-sets-container { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; } +.cred-svc-row { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + padding: 12px 14px; +} +.cred-svc-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.cred-svc-name { font-weight: 600; font-size: 0.95rem; } +.cred-add-btn { + background: rgba(var(--accent-light-rgb), 0.12); + color: rgb(var(--accent-light-rgb)); + border: 1px solid rgba(var(--accent-light-rgb), 0.35); + border-radius: 8px; padding: 5px 12px; cursor: pointer; font-size: 0.82rem; + transition: all 0.15s; +} +.cred-add-btn:hover { background: rgba(var(--accent-light-rgb), 0.22); } +.cred-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } +.cred-pill { + display: inline-flex; align-items: center; gap: 8px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; padding: 5px 6px 5px 12px; font-size: 0.85rem; +} +.cred-pill-del { + background: rgba(255, 80, 80, 0.12); color: #ff8a8a; + border: none; border-radius: 999px; width: 20px; height: 20px; + cursor: pointer; line-height: 1; font-size: 0.7rem; +} +.cred-pill-del:hover { background: rgba(255, 80, 80, 0.28); } +.cred-empty { color: rgba(255, 255, 255, 0.4); font-size: 0.85rem; font-style: italic; } +.cred-add-form { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } +.cred-input { + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; padding: 9px 12px; color: #fff; font-size: 0.85rem; +} +.cred-input:focus { outline: none; border-color: rgb(var(--accent-light-rgb)); } +.cred-form-actions { display: flex; gap: 8px; } +.cred-save-btn { + background: rgb(var(--accent-light-rgb)); color: #0a0a0a; font-weight: 600; + border: none; border-radius: 8px; padding: 7px 18px; cursor: pointer; +} +.cred-cancel-btn { + background: transparent; color: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 8px; + padding: 7px 14px; cursor: pointer; +} +.cred-form-error { color: #ff8a8a; font-size: 0.8rem; min-height: 1em; } + +/* sidebar Service Status → clickable quick-switch trigger */ +.status-section--clickable { cursor: pointer; border-radius: 10px; transition: background 0.15s; } +.status-section--clickable:hover { background: rgba(255, 255, 255, 0.04); } +.status-switch-hint { + float: right; font-size: 0.7rem; font-weight: 500; + color: rgb(var(--accent-light-rgb)); opacity: 0; transition: opacity 0.15s; +} +.status-section--clickable:hover .status-switch-hint { opacity: 0.9; } + +/* quick-switch modal */ +.cred-switch-modal { + background: #14151a; border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; width: min(560px, 92vw); max-height: 82vh; + overflow-y: auto; padding: 22px 24px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6); +} +.cred-switch-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; } +.cred-switch-title { margin: 0; font-size: 1.3rem; } +.cred-switch-sub { margin: 4px 0 0; color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; } +.cred-switch-close { + background: rgba(255, 255, 255, 0.06); border: none; color: #fff; + border-radius: 8px; width: 32px; height: 32px; cursor: pointer; font-size: 0.9rem; +} +.cred-switch-close:hover { background: rgba(255, 255, 255, 0.14); } +.cred-switch-row { padding: 12px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } +.cred-switch-row:last-child { border-bottom: none; } +.cred-switch-svc { font-weight: 600; margin-bottom: 8px; font-size: 0.92rem; } +.cred-switch-pills { display: flex; flex-wrap: wrap; gap: 8px; } +.cred-switch-pill { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 999px; padding: 6px 16px; cursor: pointer; + color: rgba(255, 255, 255, 0.8); font-size: 0.85rem; transition: all 0.15s; +} +.cred-switch-pill:hover { border-color: rgba(var(--accent-light-rgb), 0.5); } +.cred-switch-pill.active { + background: rgb(var(--accent-light-rgb)); color: #0a0a0a; + border-color: rgb(var(--accent-light-rgb)); font-weight: 600; +}