soulsync/webui/static/credential-sets.js
BoulderBadgeDad 156c890de7 Profiles Phase 1+2 (UI): admin Connected Accounts manager + quick-switch modal
Frontend for the credential-set feature, matching the blocklist/house modal
style. Functional end to end against the existing endpoints; visuals are a
clean first pass to refine.

Admin manager (Settings → Connected Accounts, admin-only — empty for non-admins):
per service, the saved accounts render as pills with a delete ✕, and "+ Add
account" reveals an inline form built from each service's required fields.
Create POSTs /api/credentials; secrets are entered but never read back (the API
only returns id/label). Loads via loadCredentialSets() at the end of
loadSettingsData().

Quick-switch modal (sidebar Service Status is now clickable for ALL profiles):
shows, per service the admin set up, a "Default" pill + one pill per account,
highlighting the profile's current choice; clicking persists via
/api/profiles/me/services/select and re-renders. Empty-state message when the
admin hasn't configured any alternates.

webui/static/credential-sets.js (new, registered in index.html), house-style
CSS appended, sidebar made clickable, settings hook added. Registered the new
module in the script-split integrity test (onclick coverage). 64 integrity
tests pass; real-app smoke confirms index renders, the asset serves, and
admin-create → per-profile-list round-trips.

Note: selections are stored but not yet consumed by the live clients (the
resolver remains dormant) — wiring playlist-pull/enrichment to use a profile's
selected account is the next step.
2026-06-10 08:58:43 -07:00

230 lines
10 KiB
JavaScript

/*
* 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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = '<div class="cred-empty">Could not load credential sets.</div>';
}
}
function _renderCredentialSets(host, services) {
host.innerHTML = CRED_SERVICES.map(svc => {
const sets = services[svc.id] || [];
const pills = sets.map(s => `
<span class="cred-pill">
<span class="cred-pill-label">${_credEsc(s.label)}</span>
<button class="cred-pill-del" title="Delete"
onclick="deleteCredentialSet(${s.id}, '${_credEsc(svc.name)}')">✕</button>
</span>`).join('');
return `
<div class="cred-svc-row" data-cred-svc="${svc.id}">
<div class="cred-svc-head">
<span class="cred-svc-name">${svc.name}</span>
<button class="cred-add-btn" onclick="toggleAddCredentialForm('${svc.id}')">+ Add account</button>
</div>
<div class="cred-pills">${pills || '<span class="cred-empty">No saved accounts</span>'}</div>
<div class="cred-add-form" id="cred-add-form-${svc.id}" hidden></div>
</div>`;
}).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 = `
<input type="text" class="cred-input" id="cred-new-label-${serviceId}" placeholder="Name (e.g. Brock's ${svc.name})">
${svc.fields.map(f => `
<input type="${f.pw ? 'password' : 'text'}" class="cred-input"
id="cred-new-${serviceId}-${f.key}" placeholder="${_credEsc(f.label)}"
autocomplete="new-password">`).join('')}
<div class="cred-form-actions">
<button class="cred-save-btn" onclick="saveNewCredential('${serviceId}')">Save</button>
<button class="cred-cancel-btn" onclick="toggleAddCredentialForm('${serviceId}')">Cancel</button>
</div>
<div class="cred-form-error" id="cred-form-error-${serviceId}"></div>`;
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 = `
<div class="cred-switch-modal">
<div class="cred-switch-head">
<div>
<h2 class="cred-switch-title">Quick Switch</h2>
<p class="cred-switch-sub">Choose which account each service uses for your profile. Set up by the admin.</p>
</div>
<button class="cred-switch-close" onclick="closeServiceSwitchModal()" aria-label="Close">✕</button>
</div>
<div class="cred-switch-body" id="cred-switch-body"></div>
</div>`;
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 = '<div class="cred-empty">Loading…</div>';
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 = '<div class="cred-empty">Could not load.</div>';
}
}
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 = [
`<button class="cred-switch-pill${selected == null ? ' active' : ''}"
onclick="selectServiceCredential('${svc.id}', null)">Default</button>`,
...info.options.map(o => `
<button class="cred-switch-pill${selected === o.id ? ' active' : ''}"
onclick="selectServiceCredential('${svc.id}', ${o.id})">${_credEsc(o.label)}</button>`),
].join('');
return `
<div class="cred-switch-row">
<div class="cred-switch-svc">${svc.name}</div>
<div class="cred-switch-pills">${pills}</div>
</div>`;
}).join('');
body.innerHTML = rows ||
'<div class="cred-empty">No alternate accounts have been set up by the admin yet.</div>';
}
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 */ }
}