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.
This commit is contained in:
BoulderBadgeDad 2026-06-10 12:21:17 -07:00
parent c154aa5442
commit e8bd9c8018
8 changed files with 376 additions and 45 deletions

View file

@ -187,18 +187,37 @@ def _build_profile_spotify_cache_key(profile_id: int, creds: Dict[str, Any]) ->
def get_spotify_client_for_profile(profile_id: Optional[int] = None):
"""Get a profile-specific Spotify client or fall back to the global one."""
"""Get a profile-specific Spotify client or fall back to the global one.
Shared-app model: a profile authenticates its OWN Spotify account through
the GLOBAL app credentials (client_id/secret) and gets its own token cache
(``.spotify_cache_profile_<id>``). A profile that set its own app creds
(legacy) still works. A profile that hasn't connected — no token cache —
falls back to the global/admin client, so nothing changes for them or for
background workers (which run as profile 1)."""
if profile_id is None or profile_id == 1:
return get_spotify_client()
try:
creds = _profile_spotify_credentials_provider(profile_id)
if not creds or not creds.get("client_id"):
return get_spotify_client()
creds = _profile_spotify_credentials_provider(profile_id) or {}
except Exception:
return get_spotify_client()
cache_key = _build_profile_spotify_cache_key(profile_id, creds)
# Effective OAuth app creds: the profile's own (legacy) else the global app.
client_id = creds.get("client_id") or _get_config_value("spotify.client_id", "")
client_secret = creds.get("client_secret") or _get_config_value("spotify.client_secret", "")
redirect_uri = (creds.get("redirect_uri")
or _get_config_value("spotify.redirect_uri", "http://127.0.0.1:8888/callback"))
import os
cache_path = f"config/.spotify_cache_profile_{profile_id}"
# Only use a per-profile client when this profile has actually connected
# (its own token cache exists). Otherwise use the global/admin client.
if not client_id or not os.path.exists(cache_path):
return get_spotify_client()
cache_key = _build_profile_spotify_cache_key(
profile_id, {"client_id": client_id, "client_secret": client_secret, "redirect_uri": redirect_uri})
with _client_cache_lock:
client = _client_cache.get(cache_key)
if client is not None and getattr(client, "sp", None) is not None:
@ -210,11 +229,11 @@ def get_spotify_client_for_profile(profile_id: Optional[int] = None):
import spotipy
auth_manager = SpotifyOAuth(
client_id=creds["client_id"],
client_secret=creds["client_secret"],
redirect_uri=creds.get("redirect_uri", "http://127.0.0.1:8888/callback"),
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
cache_path=f"config/.spotify_cache_profile_{profile_id}",
cache_path=cache_path,
state=f"profile_{profile_id}",
)

View file

@ -196,3 +196,23 @@ def test_spotify_free_composite_roundtrips_like_settings(client):
client.post('/api/profiles/active-sources', json={'metadata_source': 'spotify'})
assert config_manager.get('metadata.spotify_free') is False
assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'spotify'
# ── My Accounts: per-profile connection status (Spotify) ──────────────────────
def test_connections_status_unconnected(client, nonadmin_profile):
with client.session_transaction() as sess:
sess['profile_id'] = nonadmin_profile
body = client.get('/api/profiles/me/connections').get_json()
assert body['success'] and body['is_admin'] is False
assert body['connections']['spotify']['connected'] is False
def test_admin_connections_marks_admin(client):
body = client.get('/api/profiles/me/connections').get_json()
assert body['is_admin'] is True
def test_disconnect_admin_spotify_rejected(client):
# Admin's Spotify is the app account (Settings) — not disconnectable here.
assert client.post('/api/profiles/me/connections/spotify/disconnect').status_code == 400

View file

@ -0,0 +1,29 @@
"""Per-profile Spotify client resolution falls back safely (shared-app model).
The builder must return the GLOBAL client for admin (profile 1) and for any
non-admin profile that hasn't connected its own Spotify (no token cache) — so
background workers and existing users are unaffected. A per-profile client only
appears once that profile has its own .spotify_cache_profile_<id>.
"""
from __future__ import annotations
import os
from core.metadata import registry
def test_admin_and_none_use_global_client():
g = registry.get_spotify_client()
assert registry.get_spotify_client_for_profile(1) is g
assert registry.get_spotify_client_for_profile(None) is g
def test_unconnected_profile_falls_back_to_global(tmp_path, monkeypatch):
# A non-admin profile with no token cache must resolve to the global client.
g = registry.get_spotify_client()
# ensure no stray cache file for this id
pid = 987654
cache = f"config/.spotify_cache_profile_{pid}"
assert not os.path.exists(cache)
assert registry.get_spotify_client_for_profile(pid) is g

View file

@ -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", "service-switch.js"}
"watchlist-history.js", "service-switch.js", "my-accounts.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

View file

@ -4405,6 +4405,33 @@ def detect_soulseek_endpoint():
# --- Authentication Routes ---
def _profile_spotify_oauth(profile_id_int):
"""Build a SpotifyOAuth for a profile's connect/callback.
Shared-app model (#profiles): a profile authenticates its OWN account through
the GLOBAL app credentials and gets its own token cache. A profile that set
its own app creds (legacy) still works. show_dialog forces Spotify's account
chooser so a user can't silently inherit whatever Spotify session is active
in their browser (e.g. the admin's). Returns None if no app creds exist."""
from spotipy.oauth2 import SpotifyOAuth
creds = (get_database().get_profile_spotify(profile_id_int) or {})
cfg = config_manager.get_spotify_config()
client_id = creds.get('client_id') or cfg.get('client_id')
client_secret = creds.get('client_secret') or cfg.get('client_secret')
redirect_uri = creds.get('redirect_uri') or cfg.get('redirect_uri', 'http://127.0.0.1:8888/callback')
if not client_id or not client_secret:
return None
return SpotifyOAuth(
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
cache_path=f'config/.spotify_cache_profile_{profile_id_int}',
state=f'profile_{profile_id_int}',
show_dialog=True,
)
@app.route('/auth/spotify')
def auth_spotify():
"""
@ -4414,23 +4441,12 @@ def auth_spotify():
try:
profile_id = request.args.get('profile_id', '')
# Per-profile auth: use profile's own credentials
# Per-profile auth: the profile's own account via the shared (global) app.
if profile_id and profile_id != '1':
try:
profile_id_int = int(profile_id)
db = get_database()
creds = db.get_profile_spotify(profile_id_int)
if creds and creds.get('client_id'):
from spotipy.oauth2 import SpotifyOAuth
redirect_uri = creds.get('redirect_uri') or config_manager.get_spotify_config().get('redirect_uri', 'http://127.0.0.1:8888/callback')
auth_manager = SpotifyOAuth(
client_id=creds['client_id'],
client_secret=creds['client_secret'],
redirect_uri=redirect_uri,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
cache_path=f'config/.spotify_cache_profile_{profile_id_int}',
state=f'profile_{profile_id_int}'
)
auth_manager = _profile_spotify_oauth(profile_id_int)
if auth_manager:
auth_url = auth_manager.get_authorize_url()
logger.info(f"Per-profile Spotify auth initiated for profile {profile_id_int}")
return redirect(auth_url)
@ -4805,20 +4821,10 @@ def spotify_callback():
from spotipy.oauth2 import SpotifyOAuth
from config.settings import config_manager
# Per-profile callback: use profile's credentials
# Per-profile callback: the profile's own account via the shared app.
if profile_id_from_state and profile_id_from_state != 1:
db = get_database()
creds = db.get_profile_spotify(profile_id_from_state)
if creds and creds.get('client_id'):
redirect_uri = creds.get('redirect_uri') or config_manager.get_spotify_config().get('redirect_uri', 'http://127.0.0.1:8888/callback')
auth_manager = SpotifyOAuth(
client_id=creds['client_id'],
client_secret=creds['client_secret'],
redirect_uri=redirect_uri,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
cache_path=f'config/.spotify_cache_profile_{profile_id_from_state}',
state=f'profile_{profile_id_from_state}'
)
auth_manager = _profile_spotify_oauth(profile_id_from_state)
if auth_manager:
token_info = auth_manager.get_access_token(auth_code)
if token_info:
metadata_registry.clear_cached_profile_spotify_client(profile_id_from_state)
@ -19625,7 +19631,7 @@ def get_spotify_playlists():
# Add virtual "Liked Songs" playlist at the END (just count, no full fetch)
try:
liked_songs_count = spotify_client.get_saved_tracks_count()
liked_songs_count = client.get_saved_tracks_count()
if liked_songs_count > 0:
liked_songs_id = "spotify:liked-songs"
status_info = sync_statuses.get(liked_songs_id, {})
@ -19636,7 +19642,7 @@ def get_spotify_playlists():
sync_status = f"Synced: {last_sync_time}"
# Get user info for owner name
user_info = spotify_client.get_user_info()
user_info = client.get_user_info()
owner_name = user_info.get('display_name', 'You') if user_info else 'You'
# Add Liked Songs as LAST playlist
@ -19661,12 +19667,15 @@ def get_spotify_playlists():
@app.route('/api/spotify/playlist/<playlist_id>', methods=['GET'])
def get_playlist_tracks(playlist_id):
"""Fetches full track details for a specific playlist."""
if not spotify_client or not spotify_client.is_authenticated():
# Use THIS profile's own Spotify (their playlists/private content); falls
# back to the global/admin client for admin + unconnected profiles.
client = get_spotify_client_for_profile() or spotify_client
if not client or not client.is_authenticated():
return jsonify({"error": "Spotify not authenticated."}), 401
try:
# Handle special "Liked Songs" virtual playlist
if playlist_id == "spotify:liked-songs":
user_info = spotify_client.get_user_info()
user_info = client.get_user_info()
owner_name = user_info.get('display_name', 'You') if user_info else 'You'
# Fetch raw saved tracks with full album data
@ -19679,7 +19688,7 @@ def get_playlist_tracks(playlist_id):
return jsonify({"error": "Spotify is currently rate limited. Please try again later."}), 429
from core.api_call_tracker import api_call_tracker
api_call_tracker.record_call('spotify', endpoint='current_user_saved_tracks')
results = spotify_client.sp.current_user_saved_tracks(limit=limit, offset=offset)
results = client.sp.current_user_saved_tracks(limit=limit, offset=offset)
if not results or 'items' not in results:
break
@ -19723,7 +19732,7 @@ def get_playlist_tracks(playlist_id):
# Fetch raw playlist data to preserve full album objects
from core.api_call_tracker import api_call_tracker
api_call_tracker.record_call('spotify', endpoint='playlist')
playlist_data = spotify_client.sp.playlist(playlist_id)
playlist_data = client.sp.playlist(playlist_id)
# Fetch all tracks with full album data
tracks = []
@ -19734,7 +19743,7 @@ def get_playlist_tracks(playlist_id):
items_err = None
for _attempt in range(2):
try:
results = spotify_client._get_playlist_items_page(playlist_id, limit=100)
results = client._get_playlist_items_page(playlist_id, limit=100)
break
except Exception as _e:
items_err = _e
@ -19798,7 +19807,7 @@ def get_playlist_tracks(playlist_id):
if results.get('next'):
api_call_tracker.record_call('spotify', endpoint='playlist_tracks_page')
results = spotify_client.sp.next(results)
results = client.sp.next(results)
else:
results = None
@ -25344,6 +25353,66 @@ def test_profile_listenbrainz():
# --- Per-Profile Service Credentials API ---
def _profile_spotify_connection(profile_id):
"""(connected, account_name) for a profile's OWN connected Spotify — not the
global/admin fallback. A profile is connected only when its own token cache
exists and authenticates."""
if not profile_id or profile_id == 1:
return (False, None)
if not os.path.exists(f"config/.spotify_cache_profile_{profile_id}"):
return (False, None)
try:
client = metadata_registry.get_spotify_client_for_profile(profile_id)
if client and client.is_spotify_authenticated():
info = client.get_user_info() or {}
return (True, info.get('display_name') or info.get('id'))
except Exception as e:
logger.debug("profile %s spotify connection check failed: %s", profile_id, e)
return (False, None)
@app.route('/api/profiles/me/connections', methods=['GET'])
def get_my_connections():
"""Per-profile playlist-service connection status for the My Accounts modal.
Readable by any profile; reports only this profile's own connections."""
try:
pid = get_current_profile_id()
sp_connected, sp_account = _profile_spotify_connection(pid)
return jsonify({
'success': True,
'is_admin': pid == 1,
'connections': {
'spotify': {'connected': sp_connected, 'account': sp_account},
},
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/profiles/me/connections/spotify/disconnect', methods=['POST'])
def disconnect_my_spotify():
"""Disconnect the current profile's own Spotify (clears its token cache +
stored tokens + cached client). The global/admin Spotify is untouched."""
try:
pid = get_current_profile_id()
if pid == 1:
return jsonify({'success': False, 'error': 'The admin Spotify is managed in Settings'}), 400
cache_path = f"config/.spotify_cache_profile_{pid}"
try:
if os.path.exists(cache_path):
os.remove(cache_path)
except Exception as e:
logger.debug("could not remove profile spotify cache: %s", e)
try:
get_database().set_profile_spotify_tokens(pid, '', '')
except Exception as e:
logger.debug("could not clear profile spotify tokens: %s", e)
metadata_registry.clear_cached_profile_spotify_client(pid)
return jsonify({'success': True})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/profiles/me/spotify', methods=['GET'])
def get_profile_spotify_creds():
"""Get current profile's Spotify credentials (if set)"""

View file

@ -187,6 +187,9 @@
<div id="profile-indicator" class="profile-indicator" style="display: none;" title="Switch profile">
<div id="profile-indicator-avatar" class="profile-indicator-avatar"></div>
<span id="profile-indicator-name" class="profile-indicator-name"></span>
<button id="my-accounts-btn" class="personal-settings-trigger" onclick="event.stopPropagation(); openMyAccountsModal()" title="My Accounts">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</button>
<button id="personal-settings-btn" class="personal-settings-trigger" onclick="event.stopPropagation(); openPersonalSettings()" title="My Settings">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
@ -8191,6 +8194,7 @@
<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='service-switch.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='my-accounts.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>

145
webui/static/my-accounts.js Normal file
View file

@ -0,0 +1,145 @@
/*
* 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 */ }
}

View file

@ -67677,3 +67677,48 @@ body.em-scroll-lock { overflow: hidden; }
/* Service Status is admin-only — non-admins get no clickable affordance. */
.status-section--locked { cursor: default; }
.status-section--locked:hover { background: transparent; }
/* ── My Accounts (per-profile self-auth for playlist services) ── */
.ma-overlay { display: flex; align-items: center; justify-content: center; }
.ma-modal {
background: linear-gradient(160deg, #181a21 0%, #121319 100%);
border: 1px solid rgba(255,255,255,0.08); border-radius: 18px;
width: min(560px, 94vw); max-height: 86vh; display: flex; flex-direction: column;
overflow: hidden; box-shadow: 0 30px 80px rgba(0,0,0,0.65);
}
.ma-modal.ma-in { animation: ss-pop 0.22s cubic-bezier(0.2,0.9,0.3,1.2); }
.ma-topbar {
display: flex; align-items: center; gap: 14px; padding: 18px 20px;
border-bottom: 1px solid rgba(255,255,255,0.06); background: rgba(var(--accent-light-rgb),0.05);
}
.ma-topbar-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
.ma-topbar-logo { width: 36px; height: 36px; object-fit: contain; }
.ma-topbar-titles { flex: 1; }
.ma-topbar-title { margin: 0; font-size: 1.2rem; }
.ma-topbar-sub { color: rgba(255,255,255,0.5); font-size: 0.82rem; margin-top: 2px; }
.ma-icon-btn { background: rgba(255,255,255,0.06); border: none; color: #fff; width: 34px; height: 34px; border-radius: 9px; cursor: pointer; font-size: 1.1rem; }
.ma-icon-btn:hover { background: rgba(255,255,255,0.14); }
.ma-body { padding: 16px 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
.ma-empty { color: rgba(255,255,255,0.4); font-style: italic; padding: 24px 0; text-align: center; }
.ma-row {
display: flex; align-items: center; gap: 14px; padding: 14px 16px;
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px;
}
.ma-disc {
width: 46px; height: 46px; flex-shrink: 0; border-radius: 50%; background: #fff;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ma-brand) 55%, transparent);
}
.ma-logo { width: 28px; height: 28px; object-fit: contain; }
.ma-row-info { flex: 1; min-width: 0; }
.ma-row-name { font-weight: 600; font-size: 0.98rem; }
.ma-row-status { font-size: 0.8rem; color: rgba(255,255,255,0.45); }
.ma-row-status.is-on { color: var(--ma-brand); }
.ma-row-action { display: flex; align-items: center; gap: 10px; }
.ma-account { font-size: 0.82rem; color: rgba(255,255,255,0.7); max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ma-note { font-size: 0.78rem; color: rgba(255,255,255,0.4); font-style: italic; }
.ma-btn { border: none; border-radius: 9px; padding: 8px 18px; cursor: pointer; font-size: 0.85rem; font-weight: 600; }
.ma-btn--connect { background: var(--ma-brand); color: #0a0a0a; }
.ma-btn--connect:hover { filter: brightness(1.1); }
.ma-btn--ghost { background: transparent; color: rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.18); }
.ma-btn--ghost:hover { background: rgba(255,255,255,0.06); color: #fff; }