/* * 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 */ } }