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.
This commit is contained in:
parent
d2a167ff5f
commit
156c890de7
5 changed files with 335 additions and 3 deletions
|
|
@ -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 <script> context the last-loaded declaration wins. These are NOT
|
||||
|
|
|
|||
|
|
@ -283,8 +283,8 @@
|
|||
</div>
|
||||
|
||||
<!-- Status Section -->
|
||||
<div class="status-section">
|
||||
<h4 class="status-title">Service Status</h4>
|
||||
<div class="status-section status-section--clickable" onclick="openServiceSwitchModal()" title="Switch which account each service uses" role="button" tabindex="0">
|
||||
<h4 class="status-title">Service Status <span class="status-switch-hint">switch ▸</span></h4>
|
||||
<div class="status-indicator" id="metadata-source-indicator" data-status-ready="false">
|
||||
<span class="status-dot disconnected"></span>
|
||||
<span class="status-name" id="metadata-source-name">Metadata Source</span>
|
||||
|
|
@ -4280,6 +4280,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected Accounts (admin-managed named credential sets) -->
|
||||
<div class="settings-group" data-stg="connections">
|
||||
<h3>Connected Accounts</h3>
|
||||
<p class="settings-hint">Save multiple named logins per service. Each profile can switch between them from the sidebar's Service Status panel. Secrets are stored encrypted and never shown again.</p>
|
||||
<div id="credential-sets-container" class="credential-sets-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Server Connections -->
|
||||
<div class="settings-group" data-stg="connections">
|
||||
<h3>Server Connections</h3>
|
||||
|
|
@ -8190,6 +8197,7 @@
|
|||
<script src="{{ url_for('static', filename='origin-history.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='watchlist-history.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='blocklist.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='credential-sets.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script>
|
||||
|
|
|
|||
230
webui/static/credential-sets.js
Normal file
230
webui/static/credential-sets.js
Normal file
|
|
@ -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 = '<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 */ }
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue