soulsync/webui/static/my-accounts.js
BoulderBadgeDad e8bd9c8018 Profiles: per-profile Spotify self-auth (shared app) + My Accounts modal + read wiring
First service of the per-profile playlist-auth feature. Each profile connects
its OWN Spotify account through the shared (admin's) app, getting its own token;
used for that profile's playlist reads. Admin + unconnected profiles + all
background workers keep using the global/admin client — fully non-regressive.

- Shared-app OAuth: get_spotify_client_for_profile + the /auth/spotify init &
  callback now use the GLOBAL app creds (falling back from any legacy per-profile
  app creds) with the profile's own token cache, and show_dialog=true forces the
  account chooser so a user can't silently inherit the admin's Spotify session.
  The builder gates on the profile's own token cache existing — no cache → global.
- My Accounts modal (new, all-profile-accessible via the profile bar): one-click
  Connect/Disconnect Spotify + connection status (account name). GET
  /api/profiles/me/connections + POST .../spotify/disconnect; admin's Spotify is
  read-only here (managed in Settings).
- Wired the request-scoped reads to the per-profile client: the playlist LIST,
  the playlist TRACKS view, liked-songs count, and user info — so a connected
  user sees and opens THEIR OWN (incl. private) playlists, not the admin's.

Tests: builder falls back to the global client for admin/None/unconnected (the
non-regression guarantee); connections status reports unconnected; admin
disconnect rejected. 124 profile/spotify/gate/integrity tests pass.

Still on the global account (next step): sync/download jobs run in background
workers with no profile context — stamping the requesting profile onto the job
is the remaining wiring. Other services (Tidal/Deezer/Qobuz/Last.fm/ListenBrainz)
follow this same pattern.
2026-06-10 12:21:17 -07:00

145 lines
6.2 KiB
JavaScript

/*
* My Accounts — per-profile self-auth for playlist services.
*
* Each profile connects its OWN streaming accounts (their token, the app's
* shared client). Used for that profile's playlist operations; the global/admin
* auth keeps running the background app. Spotify is the first service; the others
* follow the same pattern.
*
* Backend: GET /api/profiles/me/connections, the per-service OAuth popups, and
* POST /api/profiles/me/connections/<service>/disconnect.
*/
// Playlist services shown in My Accounts. `connect` returns the OAuth URL for a
// given profile id (popup); services are wired in over time.
const _MA_SERVICES = [
{
id: 'spotify', name: 'Spotify', brand: '#1db954',
logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png',
connect: (pid) => `/auth/spotify?profile_id=${pid}`,
},
];
function _maEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function _maProfileId() {
try {
const ctx = (typeof getCurrentProfileContext === 'function') ? getCurrentProfileContext() : null;
return ctx ? ctx.profileId : 1;
} catch (_e) { return 1; }
}
function openMyAccountsModal() {
let overlay = document.getElementById('my-accounts-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'my-accounts-overlay';
overlay.className = 'modal-overlay ma-overlay hidden';
overlay.onclick = (e) => { if (e.target === overlay) closeMyAccountsModal(); };
overlay.innerHTML = `
<div class="ma-modal" role="dialog" aria-modal="true" aria-label="My Accounts" tabindex="-1">
<div class="ma-topbar">
<div class="ma-topbar-icon"><img src="/static/trans2.png" alt="SoulSync" class="ma-topbar-logo"></div>
<div class="ma-topbar-titles">
<h3 class="ma-topbar-title">My Accounts</h3>
<div class="ma-topbar-sub">Connect your own streaming accounts — used for your playlists, just for you.</div>
</div>
<button class="ma-icon-btn" title="Close" onclick="closeMyAccountsModal()">&times;</button>
</div>
<div class="ma-body" id="ma-body"></div>
</div>`;
document.body.appendChild(overlay);
}
overlay.classList.remove('hidden');
const modal = overlay.querySelector('.ma-modal');
if (modal) { modal.classList.remove('ma-in'); void modal.offsetWidth; modal.classList.add('ma-in'); }
document.addEventListener('keydown', _maOnKeydown);
_maLoad();
}
function closeMyAccountsModal() {
const o = document.getElementById('my-accounts-overlay');
if (o) o.classList.add('hidden');
document.removeEventListener('keydown', _maOnKeydown);
}
function _maOnKeydown(e) { if (e.key === 'Escape') closeMyAccountsModal(); }
async function _maLoad() {
const body = document.getElementById('ma-body');
if (body) body.innerHTML = '<div class="ma-empty">Loading…</div>';
let data = null;
try {
data = await (await fetch('/api/profiles/me/connections')).json();
} catch (e) { /* render disconnected */ }
_maRender(body, data || { connections: {}, is_admin: false });
}
function _maRender(body, data) {
const conns = data.connections || {};
const isAdmin = !!data.is_admin;
const rows = _MA_SERVICES.map(svc => {
const c = conns[svc.id] || {};
const connected = !!c.connected;
// Admin's Spotify is the app account managed in Settings — not a personal
// connection here.
const adminNote = (isAdmin && svc.id === 'spotify');
let action;
if (adminNote) {
action = `<span class="ma-note">Managed in Settings (app account)</span>`;
} else if (connected) {
action = `
<span class="ma-account">${_maEsc(c.account || 'Connected')}</span>
<button class="ma-btn ma-btn--ghost" onclick="disconnectMyAccount('${svc.id}')">Disconnect</button>`;
} else {
action = `<button class="ma-btn ma-btn--connect" onclick="connectMyAccount('${svc.id}')">Connect</button>`;
}
return `
<div class="ma-row" style="--ma-brand:${svc.brand}">
<span class="ma-disc"><img class="ma-logo" src="${svc.logo}" alt=""
onerror="this.style.display='none'"></span>
<div class="ma-row-info">
<div class="ma-row-name">${_maEsc(svc.name)}</div>
<div class="ma-row-status ${connected ? 'is-on' : ''}">${connected ? 'Connected' : (adminNote ? '' : 'Not connected')}</div>
</div>
<div class="ma-row-action">${action}</div>
</div>`;
}).join('');
body.innerHTML = rows || '<div class="ma-empty">No services available.</div>';
}
let _maPollTimer = null;
function connectMyAccount(serviceId) {
const svc = _MA_SERVICES.find(s => s.id === serviceId);
if (!svc) return;
const pid = _maProfileId();
const popup = window.open(svc.connect(pid), 'soulsync-connect-' + serviceId,
'width=560,height=720,menubar=no,toolbar=no');
// Poll for the popup closing, then refresh status.
if (_maPollTimer) clearInterval(_maPollTimer);
_maPollTimer = setInterval(() => {
if (!popup || popup.closed) {
clearInterval(_maPollTimer);
_maPollTimer = null;
setTimeout(_maLoad, 600); // give the callback a moment to persist
}
}, 800);
}
async function disconnectMyAccount(serviceId) {
if (!confirm(`Disconnect your ${serviceId} account from this profile?`)) return;
try {
const res = await fetch(`/api/profiles/me/connections/${serviceId}/disconnect`, { method: 'POST' });
const data = await res.json();
if (data.success) {
if (typeof showToast === 'function') showToast('Disconnected', 'success');
_maLoad();
} else if (typeof showToast === 'function') {
showToast(data.error || 'Disconnect failed', 'error');
}
} catch (e) { /* no-op */ }
}