From daee96f814ea2a07e7ce111ce9161a85a7ff9b98 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 00:27:48 -0700 Subject: [PATCH 01/52] Profiles Phase 0: service-credential-sets foundation (data + resolver, dormant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for admin-created, per-profile-switchable credential sets ("pills") across auth services (Spotify/Tidal/Deezer/Qobuz/Plex/Jellyfin/Navidrome). Strictly additive and dormant — nothing reads it at runtime yet, so zero behaviour change for existing installs. - core/credentials/store.py: pure service registry + payload validation + stale-safe active-set selection (pick_active_credential falls back to None when a selected set was deleted, so a profile never breaks). - migration service_credentials_v1: two new tables — service_credentials (admin-created named sets; payload Fernet-encrypted at rest) and profile_service_credentials (each profile's selected set per service). - MusicDatabase CRUD: create/update/delete/list/get_service_credential (list never returns the payload; get decrypts for the resolver), plus set/get_profile_service_credential and resolve_profile_service_credential (returns the profile's active payload or None → caller uses global default). Tests: 12 — pure validation + stale-safe selection, and real-temp-DB storage proving encryption round-trips, payload never lists, dup(service,label) rejected, per-profile/per-service resolution, and delete clearing dangling selections to a clean fallback. 95 migration/DB tests still pass. --- core/credentials/__init__.py | 0 core/credentials/store.py | 79 +++++++++++ database/music_database.py | 223 ++++++++++++++++++++++++++++++ tests/test_service_credentials.py | 140 +++++++++++++++++++ 4 files changed, 442 insertions(+) create mode 100644 core/credentials/__init__.py create mode 100644 core/credentials/store.py create mode 100644 tests/test_service_credentials.py diff --git a/core/credentials/__init__.py b/core/credentials/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/credentials/store.py b/core/credentials/store.py new file mode 100644 index 00000000..2d39fdf1 --- /dev/null +++ b/core/credentials/store.py @@ -0,0 +1,79 @@ +"""Named, switchable service-credential sets — pure logic (Phase 0 foundation). + +Today every auth service (Spotify, Tidal, Deezer, Qobuz, Plex, Jellyfin, +Navidrome) holds ONE credential set in config, and clients are global singletons +built from that single slot. This module is the groundwork for letting an admin +save MULTIPLE named credential sets per service ("pills") that each profile can +switch between, without anyone but the admin creating them. + +Kept PURE — service registry, payload validation, and active-set selection, +free of DB/Flask so it's unit-testable. Encrypted storage lives in MusicDatabase +(service_credentials / profile_service_credentials tables); runtime client +resolution + UI come in later phases. Nothing here changes existing behaviour; +it's dormant capability until wired. +""" + +from __future__ import annotations + +# Services that support multiple named credential sets, mapped to the payload +# keys that MUST be present for a set to be usable. Extra keys (OAuth tokens, +# redirect URIs, quality prefs) are allowed and preserved — these are only the +# minimum required to validate a set the admin is saving. +SERVICE_CREDENTIAL_SCHEMA = { + 'spotify': ('client_id', 'client_secret'), + 'tidal': ('access_token', 'refresh_token'), + 'deezer': ('arl',), + 'qobuz': ('user_auth_token',), + 'plex': ('base_url', 'token'), + 'jellyfin': ('base_url', 'api_key'), + 'navidrome': ('base_url', 'username', 'password'), +} + +SUPPORTED_SERVICES = frozenset(SERVICE_CREDENTIAL_SCHEMA) + + +def is_supported_service(service: str) -> bool: + """True when the service supports named credential sets.""" + return service in SERVICE_CREDENTIAL_SCHEMA + + +def validate_credential_payload(service: str, payload): + """Return ``(ok, missing_keys)`` for a credential set. + + Valid when every required key for the service is present and truthy. An + unknown service is invalid with no missing list (caller should reject it + as unsupported, not as "incomplete"). + """ + required = SERVICE_CREDENTIAL_SCHEMA.get(service) + if required is None: + return False, [] + if not isinstance(payload, dict): + return False, list(required) + missing = [k for k in required if not payload.get(k)] + return (not missing), missing + + +def pick_active_credential(credentials, selected_id): + """From ``credentials`` (a list of dicts each carrying ``id``), return the + one whose id == ``selected_id``. + + Returns None when there's no selection OR the selected id isn't present — + i.e. a stale pointer whose credential set was deleted. The caller then + falls back to the global/admin default, so a deleted set never breaks a + profile. Pure + stale-safe. + """ + if not selected_id: + return None + for cred in credentials or []: + if cred.get('id') == selected_id: + return cred + return None + + +__all__ = [ + 'SERVICE_CREDENTIAL_SCHEMA', + 'SUPPORTED_SERVICES', + 'is_supported_service', + 'validate_credential_payload', + 'pick_active_credential', +] diff --git a/database/music_database.py b/database/music_database.py index d87e92de..e12700a0 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -461,6 +461,7 @@ class MusicDatabase: self._add_profile_settings(cursor) self._add_profile_listenbrainz_support(cursor) self._add_profile_service_credentials(cursor) + self._add_service_credential_sets(cursor) self._add_soul_id_columns(cursor) self._add_listening_history_table(cursor) @@ -3853,6 +3854,228 @@ class MusicDatabase: except Exception as e: logger.error(f"Error in per-profile service credentials migration: {e}") + def _add_service_credential_sets(self, cursor): + """Named, switchable credential sets per auth service + each profile's + selection of which set is active (Phase 0 foundation). + + Additive only — two new tables, no change to existing tables/columns. + Dormant until the resolver + UI are wired in a later phase, so this + migration changes no runtime behaviour for existing installs. + """ + try: + cursor.execute("SELECT value FROM metadata WHERE key = 'service_credentials_v1' LIMIT 1") + if cursor.fetchone(): + return # Already migrated + + logger.info("Applying service-credential-sets migration...") + + # Admin-created named credential sets. `payload` is a Fernet-encrypted + # JSON blob (same key as per-profile tokens), so secrets stay encrypted + # at rest. UNIQUE(service, label) keeps pill names distinct per service. + cursor.execute(""" + CREATE TABLE IF NOT EXISTS service_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service TEXT NOT NULL, + label TEXT NOT NULL, + payload TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(service, label) + ) + """) + + # Per-profile selection of which credential set is active for a + # service. A missing row (or NULL credential_id) means "fall back to + # the global/admin default" — so a profile never breaks if its + # chosen set is later removed. + cursor.execute(""" + CREATE TABLE IF NOT EXISTS profile_service_credentials ( + profile_id INTEGER NOT NULL, + service TEXT NOT NULL, + credential_id INTEGER, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (profile_id, service) + ) + """) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_service_credentials_service " + "ON service_credentials (service)" + ) + + cursor.execute( + "INSERT OR REPLACE INTO metadata (key, value) VALUES ('service_credentials_v1', '1')" + ) + logger.info("Service-credential-sets migration completed") + except Exception as e: + logger.error(f"Error in service-credential-sets migration: {e}") + + # ── Service credential sets (named, switchable per profile) ────────────── + + def create_service_credential(self, service: str, label: str, payload: dict): + """Create a named credential set for a service. Returns the new id, or + None on failure / duplicate (service, label). Payload is encrypted.""" + try: + from config.settings import config_manager + enc = config_manager._encrypt_value(payload) if payload else None + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO service_credentials (service, label, payload) VALUES (?, ?, ?)", + (service, label, enc), + ) + conn.commit() + return cursor.lastrowid + except sqlite3.IntegrityError: + logger.warning(f"Service credential '{label}' already exists for {service}") + return None + except Exception as e: + logger.error(f"Error creating service credential ({service}/{label}): {e}") + return None + + def update_service_credential(self, credential_id: int, label: str = None, + payload: dict = None) -> bool: + """Update a credential set's label and/or payload. Only provided fields + change. Returns True if a row was updated.""" + try: + from config.settings import config_manager + sets, params = [], [] + if label is not None: + sets.append("label = ?") + params.append(label) + if payload is not None: + sets.append("payload = ?") + params.append(config_manager._encrypt_value(payload) if payload else None) + if not sets: + return False + sets.append("updated_at = CURRENT_TIMESTAMP") + params.append(credential_id) + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + f"UPDATE service_credentials SET {', '.join(sets)} WHERE id = ?", params + ) + conn.commit() + return cursor.rowcount > 0 + except sqlite3.IntegrityError: + logger.warning(f"Rename of credential {credential_id} collides with an existing label") + return False + except Exception as e: + logger.error(f"Error updating service credential {credential_id}: {e}") + return False + + def delete_service_credential(self, credential_id: int) -> bool: + """Delete a credential set and clear any profile selections that point + at it (so those profiles fall back to the global default). Returns True + if the set existed.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE profile_service_credentials SET credential_id = NULL, " + "updated_at = CURRENT_TIMESTAMP WHERE credential_id = ?", + (credential_id,), + ) + cursor.execute("DELETE FROM service_credentials WHERE id = ?", (credential_id,)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error deleting service credential {credential_id}: {e}") + return False + + def list_service_credentials(self, service: str = None): + """List credential sets (metadata only — never the payload). Optionally + filtered to one service. Returns dicts: id, service, label, timestamps.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + if service: + cursor.execute( + "SELECT id, service, label, created_at, updated_at FROM service_credentials " + "WHERE service = ? ORDER BY label COLLATE NOCASE", + (service,), + ) + else: + cursor.execute( + "SELECT id, service, label, created_at, updated_at FROM service_credentials " + "ORDER BY service, label COLLATE NOCASE" + ) + return [ + {'id': r[0], 'service': r[1], 'label': r[2], + 'created_at': r[3], 'updated_at': r[4]} + for r in cursor.fetchall() + ] + except Exception as e: + logger.error(f"Error listing service credentials: {e}") + return [] + + def get_service_credential(self, credential_id: int): + """Get a credential set WITH its decrypted payload, or None. For the + resolver / client wiring — not for shipping to the browser.""" + try: + from config.settings import config_manager + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT id, service, label, payload FROM service_credentials WHERE id = ?", + (credential_id,), + ) + row = cursor.fetchone() + if not row: + return None + payload = config_manager._decrypt_value(row[3]) if row[3] else {} + return {'id': row[0], 'service': row[1], 'label': row[2], + 'payload': payload if isinstance(payload, dict) else {}} + except Exception as e: + logger.error(f"Error reading service credential {credential_id}: {e}") + return None + + def set_profile_service_credential(self, profile_id: int, service: str, + credential_id) -> bool: + """Select which credential set is active for a profile + service. + Pass credential_id=None to clear (fall back to global). Upsert.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO profile_service_credentials (profile_id, service, credential_id) " + "VALUES (?, ?, ?) " + "ON CONFLICT(profile_id, service) DO UPDATE SET " + "credential_id = excluded.credential_id, updated_at = CURRENT_TIMESTAMP", + (profile_id, service, credential_id), + ) + conn.commit() + return True + except Exception as e: + logger.error(f"Error setting profile {profile_id} {service} credential: {e}") + return False + + def get_profile_service_credential_id(self, profile_id: int, service: str): + """Return the credential_id a profile selected for a service, or None.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT credential_id FROM profile_service_credentials " + "WHERE profile_id = ? AND service = ?", + (profile_id, service), + ) + row = cursor.fetchone() + return row[0] if row else None + except Exception as e: + logger.error(f"Error reading profile {profile_id} {service} selection: {e}") + return None + + def resolve_profile_service_credential(self, profile_id: int, service: str): + """Resolve a profile's ACTIVE credential payload for a service: the + decrypted payload of its selected set, or None when it hasn't selected + one (or the set was deleted) — caller then uses the global/admin default. + Stale-safe: a dangling selection resolves to None, not an error.""" + cred_id = self.get_profile_service_credential_id(profile_id, service) + if not cred_id: + return None + cred = self.get_service_credential(cred_id) + return cred['payload'] if cred else None + def _add_soul_id_columns(self, cursor): """Add soul_id columns to artists, albums, and tracks tables.""" try: diff --git a/tests/test_service_credentials.py b/tests/test_service_credentials.py new file mode 100644 index 00000000..aec802ec --- /dev/null +++ b/tests/test_service_credentials.py @@ -0,0 +1,140 @@ +"""Phase 0: named, switchable service-credential sets. + +Foundation for letting an admin save multiple named credential sets per auth +service ("pills") that each profile can switch between. These cover the PURE +selection/validation logic and the encrypted DB storage + per-profile selection ++ resolver — with real temp databases (not mocks) so encryption round-trips and +the stale-selection fallback are genuinely exercised. + +This layer is dormant (nothing reads it at runtime yet), so it can't regress +existing behaviour — the tests pin the contract the later wiring will rely on. +""" + +from __future__ import annotations + +import pytest + +from core.credentials.store import ( + SUPPORTED_SERVICES, + is_supported_service, + validate_credential_payload, + pick_active_credential, +) +from database.music_database import MusicDatabase + + +# ── pure: validation ──────────────────────────────────────────────────────── + +def test_supported_services_cover_the_auth_sources(): + for s in ('spotify', 'tidal', 'deezer', 'qobuz', 'plex', 'jellyfin', 'navidrome'): + assert is_supported_service(s) + assert not is_supported_service('itunes') # no auth → not a credential service + assert not is_supported_service('musicbrainz') + + +def test_validate_payload_ok_and_missing(): + ok, missing = validate_credential_payload('spotify', {'client_id': 'a', 'client_secret': 'b'}) + assert ok and missing == [] + ok, missing = validate_credential_payload('spotify', {'client_id': 'a'}) + assert not ok and missing == ['client_secret'] + + +def test_validate_payload_unknown_service_and_non_dict(): + assert validate_credential_payload('nope', {'x': 1}) == (False, []) + ok, missing = validate_credential_payload('plex', None) + assert not ok and set(missing) == {'base_url', 'token'} + + +def test_validate_treats_empty_string_as_missing(): + ok, missing = validate_credential_payload('navidrome', + {'base_url': 'http://x', 'username': '', 'password': 'p'}) + assert not ok and missing == ['username'] + + +# ── pure: active-set selection (stale-safe) ────────────────────────────────── + +def test_pick_active_credential_match_and_misses(): + creds = [{'id': 1, 'label': 'A'}, {'id': 2, 'label': 'B'}] + assert pick_active_credential(creds, 2)['label'] == 'B' + assert pick_active_credential(creds, None) is None # no selection + assert pick_active_credential(creds, 99) is None # stale id (set deleted) + assert pick_active_credential([], 1) is None + assert pick_active_credential(None, 1) is None + + +# ── DB: storage + selection + resolver (real temp DB, real encryption) ─────── + +@pytest.fixture +def db(tmp_path): + return MusicDatabase(database_path=str(tmp_path / 'creds.db')) + + +def test_create_get_roundtrip_encrypts_payload(db, tmp_path): + cid = db.create_service_credential('spotify', "Brock's Spotify", + {'client_id': 'abc', 'client_secret': 'sek'}) + assert cid + got = db.get_service_credential(cid) + assert got['service'] == 'spotify' and got['label'] == "Brock's Spotify" + assert got['payload'] == {'client_id': 'abc', 'client_secret': 'sek'} + # The on-disk payload must be ciphertext, never the plaintext secret. + import sqlite3 + raw = sqlite3.connect(str(tmp_path / 'creds.db')).execute( + "SELECT payload FROM service_credentials WHERE id = ?", (cid,)).fetchone()[0] + assert 'sek' not in raw and raw.startswith('gAAAAA') + + +def test_duplicate_label_per_service_rejected(db): + assert db.create_service_credential('spotify', 'Main', {'client_id': 'a', 'client_secret': 'b'}) + assert db.create_service_credential('spotify', 'Main', {'client_id': 'c', 'client_secret': 'd'}) is None + # same label is fine under a DIFFERENT service + assert db.create_service_credential('tidal', 'Main', {'access_token': 't', 'refresh_token': 'r'}) + + +def test_list_never_exposes_payload(db): + db.create_service_credential('spotify', 'One', {'client_id': 'a', 'client_secret': 'b'}) + db.create_service_credential('deezer', 'Two', {'arl': 'xyz'}) + rows = db.list_service_credentials() + assert {r['label'] for r in rows} == {'One', 'Two'} + assert all('payload' not in r for r in rows) + assert [r['label'] for r in db.list_service_credentials('deezer')] == ['Two'] + + +def test_update_label_and_payload(db): + cid = db.create_service_credential('qobuz', 'Q', {'user_auth_token': 'tok'}) + assert db.update_service_credential(cid, label='Q renamed') + assert db.update_service_credential(cid, payload={'user_auth_token': 'newtok'}) + got = db.get_service_credential(cid) + assert got['label'] == 'Q renamed' and got['payload']['user_auth_token'] == 'newtok' + + +def test_profile_selection_resolves_and_falls_back(db): + cid = db.create_service_credential('spotify', 'Shared', {'client_id': 'a', 'client_secret': 'b'}) + # No selection → None (caller uses global default) + assert db.resolve_profile_service_credential(7, 'spotify') is None + db.set_profile_service_credential(7, 'spotify', cid) + assert db.resolve_profile_service_credential(7, 'spotify') == {'client_id': 'a', 'client_secret': 'b'} + # Clearing the selection falls back again + db.set_profile_service_credential(7, 'spotify', None) + assert db.resolve_profile_service_credential(7, 'spotify') is None + + +def test_delete_clears_selections_and_resolves_to_fallback(db): + cid = db.create_service_credential('spotify', 'Temp', {'client_id': 'a', 'client_secret': 'b'}) + db.set_profile_service_credential(2, 'spotify', cid) + db.set_profile_service_credential(9, 'spotify', cid) + assert db.delete_service_credential(cid) + # Both profiles' dangling selections resolve to None, not an error. + assert db.resolve_profile_service_credential(2, 'spotify') is None + assert db.resolve_profile_service_credential(9, 'spotify') is None + assert db.get_service_credential(cid) is None + + +def test_selection_is_per_profile_and_per_service(db): + sp = db.create_service_credential('spotify', 'SP', {'client_id': 'a', 'client_secret': 'b'}) + td = db.create_service_credential('tidal', 'TD', {'access_token': 't', 'refresh_token': 'r'}) + db.set_profile_service_credential(1, 'spotify', sp) + db.set_profile_service_credential(1, 'tidal', td) + assert db.resolve_profile_service_credential(1, 'spotify')['client_id'] == 'a' + assert db.resolve_profile_service_credential(1, 'tidal')['access_token'] == 't' + # A different profile shares the pool but has its own (empty) selection. + assert db.resolve_profile_service_credential(2, 'spotify') is None From d1dd6ed7149aef8085d70decf00877eb0f536f33 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 00:33:13 -0700 Subject: [PATCH 02/52] Profiles Phase 1: admin endpoints to manage service-credential sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-only CRUD over the named credential sets from Phase 0: - GET /api/credentials list all sets grouped by service (NO secrets) - POST /api/credentials create {service,label,payload}, validated - PUT /api/credentials/ update label and/or payload (partial) - DELETE /api/credentials/ delete (clears any profile selections → fallback) All four are @admin_only (non-admin → 403), payloads validated via core.credentials.store, secrets never returned to the browser. Additive — no existing endpoint or behaviour changes. Tests: real web_server app + Flask test client (8) — create/list/update/delete roundtrip, payload never leaks in list, missing-field/unsupported-service/blank- label/duplicate(409)/404 validation, and the non-admin 403 gate on every write. Verified the web_server import coexists with the rest of the suite (175 mixed tests pass). --- tests/test_credentials_endpoints.py | 108 ++++++++++++++++++++++++++++ web_server.py | 100 ++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 tests/test_credentials_endpoints.py diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py new file mode 100644 index 00000000..5542736a --- /dev/null +++ b/tests/test_credentials_endpoints.py @@ -0,0 +1,108 @@ +"""Phase 1: service-credential-set admin endpoints (real app, real HTTP). + +These import the actual web_server app and drive the endpoints through a Flask +test client — the only way to verify the @admin_only gating and the request +validation wrappers for real. Secrets must never come back in any response. + +Heavy (imports web_server once), so isolated in its own module. The default +session is profile 1 (admin); a non-admin session is simulated to prove the +gate blocks writes. +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +# Redirect the DB before importing web_server so it never touches a real library. +_TMP = tempfile.mkdtemp(prefix='ss-cred-ep-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'creds_ep.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' + +web_server = pytest.importorskip('web_server') + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +@pytest.fixture +def nonadmin_profile(): + """Create a real non-admin profile and yield its id.""" + db = web_server.get_database() + pid = db.create_profile(name=f'tester_{os.urandom(3).hex()}', avatar_color='#fff') + yield pid + + +# ── admin happy paths ──────────────────────────────────────────────────────── + +def test_admin_create_list_update_delete_roundtrip(client): + r = client.post('/api/credentials', json={ + 'service': 'plex', 'label': 'Living Room', + 'payload': {'base_url': 'http://plex:32400', 'token': 'sekret'}}) + assert r.status_code == 200 and r.get_json()['success'] + cid = r.get_json()['id'] + + # list shows it, and NEVER leaks the payload/secret + body = client.get('/api/credentials').get_json() + assert any(c['label'] == 'Living Room' for c in body['services']['plex']) + assert 'sekret' not in str(body) and 'payload' not in str(body) + + # update label + assert client.put(f'/api/credentials/{cid}', json={'label': 'Den'}).get_json()['success'] + body = client.get('/api/credentials').get_json() + assert any(c['label'] == 'Den' for c in body['services']['plex']) + + # delete + assert client.delete(f'/api/credentials/{cid}').get_json()['success'] + body = client.get('/api/credentials').get_json() + assert not any(c['id'] == cid for c in body['services']['plex']) + + +# ── validation ─────────────────────────────────────────────────────────────── + +def test_create_rejects_missing_fields(client): + r = client.post('/api/credentials', json={ + 'service': 'plex', 'label': 'X', 'payload': {'base_url': 'http://p'}}) + assert r.status_code == 400 and 'token' in r.get_json()['error'] + + +def test_create_rejects_unsupported_service(client): + r = client.post('/api/credentials', json={'service': 'itunes', 'label': 'X', 'payload': {}}) + assert r.status_code == 400 + + +def test_create_rejects_blank_label(client): + r = client.post('/api/credentials', json={ + 'service': 'deezer', 'label': ' ', 'payload': {'arl': 'x'}}) + assert r.status_code == 400 + + +def test_duplicate_label_conflict(client): + p = {'service': 'qobuz', 'label': 'Dup', 'payload': {'user_auth_token': 't'}} + assert client.post('/api/credentials', json=p).status_code == 200 + assert client.post('/api/credentials', json=p).status_code == 409 + + +def test_update_missing_set_404(client): + assert client.put('/api/credentials/999999', json={'label': 'x'}).status_code == 404 + + +def test_delete_missing_set_404(client): + assert client.delete('/api/credentials/999999').status_code == 404 + + +# ── the security gate: non-admin cannot manage credential sets ─────────────── + +def test_nonadmin_blocked_from_all_credential_writes(client, nonadmin_profile): + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + assert client.get('/api/credentials').status_code == 403 + assert client.post('/api/credentials', json={ + 'service': 'plex', 'label': 'Sneaky', + 'payload': {'base_url': 'http://p', 'token': 't'}}).status_code == 403 + assert client.put('/api/credentials/1', json={'label': 'x'}).status_code == 403 + assert client.delete('/api/credentials/1').status_code == 403 diff --git a/web_server.py b/web_server.py index 59bf8074..36fb9aeb 100644 --- a/web_server.py +++ b/web_server.py @@ -25421,6 +25421,106 @@ def save_profile_server_library(): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 + +# ================================================================================== +# SERVICE CREDENTIAL SETS (admin-created named "pills" per auth service; #profiles) +# ---------------------------------------------------------------------------------- +# Admin manages the named credential sets; any profile only SELECTS among them +# (see /api/credentials/active + /select below). Payloads (the actual secrets) +# are NEVER returned to the browser — only id/service/label. +# ================================================================================== + +@app.route('/api/credentials', methods=['GET']) +@admin_only +def list_service_credentials_endpoint(): + """List all credential sets grouped by service (metadata only, no secrets).""" + try: + from core.credentials.store import SERVICE_CREDENTIAL_SCHEMA + rows = get_database().list_service_credentials() + grouped = {svc: [] for svc in SERVICE_CREDENTIAL_SCHEMA} + for r in rows: + grouped.setdefault(r['service'], []).append( + {'id': r['id'], 'label': r['label'], 'updated_at': r['updated_at']} + ) + return jsonify({'success': True, 'services': grouped}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/credentials', methods=['POST']) +@admin_only +def create_service_credential_endpoint(): + """Create a named credential set for a service. Body: {service, label, payload}.""" + try: + from core.credentials.store import is_supported_service, validate_credential_payload + data = request.json or {} + service = (data.get('service') or '').strip() + label = (data.get('label') or '').strip() + payload = data.get('payload') or {} + + if not is_supported_service(service): + return jsonify({'success': False, 'error': f'Unsupported service: {service}'}), 400 + if not label: + return jsonify({'success': False, 'error': 'A name is required'}), 400 + ok, missing = validate_credential_payload(service, payload) + if not ok: + return jsonify({'success': False, 'error': f'Missing required fields: {", ".join(missing)}'}), 400 + + cred_id = get_database().create_service_credential(service, label, payload) + if cred_id is None: + return jsonify({'success': False, 'error': f'A "{label}" set already exists for {service}'}), 409 + return jsonify({'success': True, 'id': cred_id}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/credentials/', methods=['PUT']) +@admin_only +def update_service_credential_endpoint(credential_id): + """Update a credential set's label and/or payload. Only provided fields change.""" + try: + from core.credentials.store import validate_credential_payload + data = request.json or {} + label = data.get('label') + payload = data.get('payload') # None = leave secrets untouched + + existing = get_database().get_service_credential(credential_id) + if not existing: + return jsonify({'success': False, 'error': 'Credential set not found'}), 404 + + if label is not None and not str(label).strip(): + return jsonify({'success': False, 'error': 'Name cannot be empty'}), 400 + if payload is not None: + ok, missing = validate_credential_payload(existing['service'], payload) + if not ok: + return jsonify({'success': False, 'error': f'Missing required fields: {", ".join(missing)}'}), 400 + + updated = get_database().update_service_credential( + credential_id, + label=str(label).strip() if label is not None else None, + payload=payload, + ) + if not updated: + return jsonify({'success': False, 'error': 'Nothing to update or name already in use'}), 400 + return jsonify({'success': True}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/credentials/', methods=['DELETE']) +@admin_only +def delete_service_credential_endpoint(credential_id): + """Delete a credential set. Any profile that had it selected falls back to + the global/admin default automatically (selection is cleared in the DB).""" + try: + ok = get_database().delete_service_credential(credential_id) + if not ok: + return jsonify({'success': False, 'error': 'Credential set not found'}), 404 + return jsonify({'success': True}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + # --- Watchlist API Endpoints --- @app.route('/api/watchlist/count', methods=['GET']) From af24a08cc7a6ade3d131fd1d36c6592c6c824454 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 00:37:56 -0700 Subject: [PATCH 03/52] Profiles Phase 3: gate shared-destructive endpoints to admin server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit found these were UI-hidden but API-open — any profile (or the anonymous default-admin) could call them directly. Added @admin_only to the 15 that mutate SHARED/global state: - DB: update, backup, backups DELETE, restore, vacuum - library: track DELETE, album DELETE, tracks delete-batch, clear-match - plex clear-library; metadata-cache clear + clear-musicbrainz - internal API keys: list, generate, revoke Deliberately NOT gated: profile-scoped own-data ops like /api/wishlist/clear (clears the caller's OWN wishlist via profile_id) — gating that would wrongly block a non-admin from managing their own data. Verified by test. Zero change for single-profile installs (the default profile IS admin), so existing users are unaffected; only genuine non-admin profiles get 403. Tests: non-admin → 403 on all 15 (the 403 fires before the view body, so no destructive op runs); admin not blocked on the read-only one; wishlist/clear stays open to non-admins (over-gating guard). 17 tests. --- tests/test_admin_gating.py | 80 ++++++++++++++++++++++++++++++++++++++ web_server.py | 15 +++++++ 2 files changed, 95 insertions(+) create mode 100644 tests/test_admin_gating.py diff --git a/tests/test_admin_gating.py b/tests/test_admin_gating.py new file mode 100644 index 00000000..6ab66818 --- /dev/null +++ b/tests/test_admin_gating.py @@ -0,0 +1,80 @@ +"""Phase 3: server-side admin gating of shared/global-destructive endpoints. + +The audit found these were callable by any profile (UI hid them, the API didn't). +For a real multi-user setup that's unsafe — a non-admin could restore/vacuum the +DB, wipe the shared library, clear the Plex library, or mint API keys. These +assert the @admin_only gate now blocks non-admins, that admin is NOT blocked +(zero change for single-profile installs, where everyone is the default admin), +and crucially that a PROFILE-SCOPED op (clearing your OWN wishlist) was NOT +over-gated. +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +_TMP = tempfile.mkdtemp(prefix='ss-admin-gate-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'gate.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' + +web_server = pytest.importorskip('web_server') + + +# (method, path) for every endpoint that must be admin-only. +GATED = [ + ('GET', '/api/v1/api-keys-internal'), + ('POST', '/api/v1/api-keys-internal/generate'), + ('DELETE', '/api/v1/api-keys-internal/revoke/abc'), + ('POST', '/api/plex/clear-library'), + ('PUT', '/api/library/clear-match'), + ('DELETE', '/api/library/track/123'), + ('DELETE', '/api/library/album/123'), + ('POST', '/api/library/tracks/delete-batch'), + ('POST', '/api/database/update'), + ('POST', '/api/database/backup'), + ('DELETE', '/api/database/backups/x.db'), + ('POST', '/api/database/backups/x.db/restore'), + ('POST', '/api/database/maintenance/vacuum'), + ('DELETE', '/api/metadata-cache/clear'), + ('DELETE', '/api/metadata-cache/clear-musicbrainz'), +] + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +@pytest.fixture +def nonadmin(client): + pid = web_server.get_database().create_profile(name=f'u_{os.urandom(3).hex()}') + with client.session_transaction() as sess: + sess['profile_id'] = pid + return pid + + +def _call(client, method, path): + return client.open(path, method=method, json={}) + + +@pytest.mark.parametrize('method,path', GATED) +def test_nonadmin_blocked(client, nonadmin, method, path): + # @admin_only returns 403 BEFORE the view body runs, so this never triggers + # the underlying destructive operation — safe to assert across all of them. + assert _call(client, method, path).status_code == 403, f"{method} {path} should be 403 for non-admin" + + +def test_admin_not_blocked_by_the_gate(client): + # Default session = profile 1 (admin). Prove the gate lets admin through on a + # SAFE, read-only gated endpoint (listing API keys) — confirming the no-change + # guarantee for single-profile installs without triggering a destructive op. + assert client.get('/api/v1/api-keys-internal').status_code != 403 + + +def test_profile_scoped_wishlist_clear_not_overgated(client, nonadmin): + # Clearing your OWN wishlist is profile-scoped data — a non-admin MUST still + # be allowed. This is the guard against a blanket sweep. + assert _call(client, 'POST', '/api/wishlist/clear').status_code != 403 diff --git a/web_server.py b/web_server.py index 36fb9aeb..3b195811 100644 --- a/web_server.py +++ b/web_server.py @@ -2927,6 +2927,7 @@ def get_activity_logs(): # --- Internal API Key Management (browser-only, no auth) --- @app.route('/api/v1/api-keys-internal', methods=['GET']) +@admin_only def list_api_keys_internal(): """List API keys for the settings page (no auth required — same as all UI routes).""" keys = config_manager.get('api_keys', []) @@ -2943,6 +2944,7 @@ def list_api_keys_internal(): return jsonify({"success": True, "data": {"keys": safe_keys}}) @app.route('/api/v1/api-keys-internal/generate', methods=['POST']) +@admin_only def generate_api_key_internal(): """Generate API key from settings page (no auth required).""" from api.auth import generate_api_key @@ -2961,6 +2963,7 @@ def generate_api_key_internal(): }}), 201 @app.route('/api/v1/api-keys-internal/revoke/', methods=['DELETE']) +@admin_only def revoke_api_key_internal(key_id): """Revoke API key from settings page (no auth required).""" keys = config_manager.get('api_keys', []) @@ -4040,6 +4043,7 @@ def get_plex_pin_status(): @app.route('/api/plex/clear-library', methods=['POST']) +@admin_only def clear_plex_library_preference(): try: from database.music_database import MusicDatabase @@ -11636,6 +11640,7 @@ def library_manual_match(): logger.error(f"Error manual matching: {e}") @app.route('/api/library/clear-match', methods=['PUT']) +@admin_only def library_clear_match(): """Clear a service ID match for an entity, reverting it to not_found. Body: { entity_type: str, entity_id: str, service: str } @@ -11751,6 +11756,7 @@ def library_import_existing_track_for_missing_slot(album_id): @app.route('/api/library/track/', methods=['DELETE']) +@admin_only def library_delete_track(track_id): """Delete a track from the database, optionally deleting the file and blacklisting the source.""" try: @@ -12322,6 +12328,7 @@ def sync_artist_library(artist_id): return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/album/', methods=['DELETE']) +@admin_only def library_delete_album(album_id): """Delete an album and all its tracks from the database, optionally deleting files on disk.""" try: @@ -12399,6 +12406,7 @@ def library_delete_album(album_id): @app.route('/api/library/tracks/delete-batch', methods=['POST']) +@admin_only def library_delete_tracks_batch(): """Delete multiple track records from the database (does NOT delete files on disk). Body: { track_ids: [int] } @@ -16055,6 +16063,7 @@ def add_album_track_to_wishlist(): return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/database/update', methods=['POST']) +@admin_only def start_database_update(): """Endpoint to start the database update process.""" global db_update_worker @@ -16111,6 +16120,7 @@ def stop_database_update(): _BACKUP_FILENAME_RE = re.compile(r'^music_library\.db\.backup_\d{8}_\d{6}$') @app.route('/api/database/backup', methods=['POST']) +@admin_only def backup_database_endpoint(): """Create a rolling backup of the database (max 5).""" try: @@ -16202,6 +16212,7 @@ def list_backups_endpoint(): return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/database/backups/', methods=['DELETE']) +@admin_only def delete_backup_endpoint(filename): """Delete a specific database backup.""" try: @@ -16224,6 +16235,7 @@ def delete_backup_endpoint(filename): return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/database/backups//restore', methods=['POST']) +@admin_only def restore_backup_endpoint(filename): """Restore the database from a specific backup.""" try: @@ -16367,6 +16379,7 @@ def database_maintenance_info(): @app.route('/api/database/maintenance/vacuum', methods=['POST']) +@admin_only def database_vacuum(): """Run VACUUM to compact the database. Locks DB during operation.""" try: @@ -16556,6 +16569,7 @@ def metadata_cache_browse_musicbrainz(): return jsonify({"error": str(e)}), 500 @app.route('/api/metadata-cache/clear', methods=['DELETE']) +@admin_only def metadata_cache_clear(): """Clear cached metadata. Optional query params: source, type.""" try: @@ -16583,6 +16597,7 @@ def metadata_cache_evict(): return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/metadata-cache/clear-musicbrainz', methods=['DELETE']) +@admin_only def metadata_cache_clear_musicbrainz(): """Clear MusicBrainz cache entries. Optional query param: failed_only=true.""" try: From 4ef12d7ddfe10aac2bfe3b43b4cabaf6968120c7 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 00:40:26 -0700 Subject: [PATCH 04/52] Profiles Phase 2 (backend): per-profile credential selection endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets any profile pick which admin-created credential set is active for it, without creating/seeing secrets: - GET /api/profiles/me/services per-service options (id+label only) + this profile's selected_id (stale-safe) - POST /api/profiles/me/services/select {service, credential_id|null} Not admin-gated by design — it only writes a per-profile pointer and exposes no secrets. Validates the chosen set exists AND belongs to that service (can't select a tidal set under spotify), and rejects unsupported services. null clears back to the global/admin default. Tests: a non-admin reads options + selects + clears (no secret in the response), and selection rejects wrong-service / nonexistent / unsupported. 10 endpoint tests total. --- tests/test_credentials_endpoints.py | 43 ++++++++++++++++++++++ web_server.py | 57 +++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index 5542736a..e4399115 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -106,3 +106,46 @@ def test_nonadmin_blocked_from_all_credential_writes(client, nonadmin_profile): 'payload': {'base_url': 'http://p', 'token': 't'}}).status_code == 403 assert client.put('/api/credentials/1', json={'label': 'x'}).status_code == 403 assert client.delete('/api/credentials/1').status_code == 403 + + +# ── Phase 2: per-profile selection (any profile selects among existing sets) ── + +def test_profile_selects_among_existing_sets(client, nonadmin_profile): + # Admin creates two Spotify sets. + a = client.post('/api/credentials', json={'service': 'spotify', 'label': 'Acct A', + 'payload': {'client_id': 'a', 'client_secret': 's'}}).get_json()['id'] + b = client.post('/api/credentials', json={'service': 'spotify', 'label': 'Acct B', + 'payload': {'client_id': 'b', 'client_secret': 's'}}).get_json()['id'] + + # Switch to a non-admin session — it can still READ options + SELECT. + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + + svc = client.get('/api/profiles/me/services').get_json()['services']['spotify'] + assert {o['id'] for o in svc['options']} == {a, b} + assert svc['selected_id'] is None + assert 'secret' not in str(svc) and 's' not in [o.get('client_secret') for o in svc['options'] if 'client_secret' in o] + + assert client.post('/api/profiles/me/services/select', + json={'service': 'spotify', 'credential_id': b}).get_json()['success'] + svc = client.get('/api/profiles/me/services').get_json()['services']['spotify'] + assert svc['selected_id'] == b + + # Clear → back to None + assert client.post('/api/profiles/me/services/select', + json={'service': 'spotify', 'credential_id': None}).get_json()['success'] + assert client.get('/api/profiles/me/services').get_json()['services']['spotify']['selected_id'] is None + + +def test_select_rejects_wrong_service_or_missing_set(client): + sp = client.post('/api/credentials', json={'service': 'spotify', 'label': 'X', + 'payload': {'client_id': 'a', 'client_secret': 's'}}).get_json()['id'] + # Selecting a spotify set under 'tidal' must be rejected. + assert client.post('/api/profiles/me/services/select', + json={'service': 'tidal', 'credential_id': sp}).status_code == 400 + # Nonexistent id rejected. + assert client.post('/api/profiles/me/services/select', + json={'service': 'spotify', 'credential_id': 999999}).status_code == 400 + # Unsupported service rejected. + assert client.post('/api/profiles/me/services/select', + json={'service': 'itunes', 'credential_id': None}).status_code == 400 diff --git a/web_server.py b/web_server.py index 3b195811..805ed158 100644 --- a/web_server.py +++ b/web_server.py @@ -25536,6 +25536,63 @@ def delete_service_credential_endpoint(credential_id): return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/profiles/me/services', methods=['GET']) +def get_my_service_selections(): + """For the current profile: the available credential sets per service (id + + label, never secrets) and which one this profile has selected. Drives the + quick-switch modal's pills. Any profile may read this — it exposes no + secrets, only the admin-created set names. Stale-safe: a selection whose set + was deleted reports as None (fall back to the global/admin default).""" + try: + from core.credentials.store import SERVICE_CREDENTIAL_SCHEMA + db = get_database() + profile_id = get_current_profile_id() + out = {} + for service in SERVICE_CREDENTIAL_SCHEMA: + options = db.list_service_credentials(service) + selected = db.get_profile_service_credential_id(profile_id, service) + if selected not in {o['id'] for o in options}: + selected = None + out[service] = { + 'options': [{'id': o['id'], 'label': o['label']} for o in options], + 'selected_id': selected, + } + return jsonify({'success': True, 'services': out}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/profiles/me/services/select', methods=['POST']) +def select_my_service_credential(): + """Set which admin-created credential set is active for the current profile + on a service. ``credential_id=null`` clears it (fall back to the global/admin + default). The caller can only pick an EXISTING set for that service — never + create one — so a non-admin can switch their account but not configure new + credentials. Not admin-gated by design: it only writes a per-profile pointer + and exposes no secrets.""" + try: + from core.credentials.store import is_supported_service + data = request.json or {} + service = (data.get('service') or '').strip() + credential_id = data.get('credential_id') + + if not is_supported_service(service): + return jsonify({'success': False, 'error': f'Unsupported service: {service}'}), 400 + + db = get_database() + if credential_id is not None: + cred = db.get_service_credential(credential_id) + if not cred or cred['service'] != service: + return jsonify({'success': False, 'error': 'No such credential set for this service'}), 400 + + ok = db.set_profile_service_credential(get_current_profile_id(), service, credential_id) + if ok: + return jsonify({'success': True}) + return jsonify({'success': False, 'error': 'Failed to save selection'}), 500 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + # --- Watchlist API Endpoints --- @app.route('/api/watchlist/count', methods=['GET']) From bb0f68a8bf1b057fb000404262b212839007002d Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 00:43:04 -0700 Subject: [PATCH 05/52] Profiles Phase 2 (drag): make the hybrid-source reorder actually draggable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hybrid download-source list set item.draggable=true and the help text said "drag to reorder", but no drag handlers were wired — only the arrow buttons worked (and _syncHybridOrderFromDOM was dead code). Wired real dragstart/dragover/drop on each item, reordering _hybridVisualOrder (the same model moveHybridSource uses) then rebuilding + autosaving. Added grab/grabbing cursors + a dragging state. The arrow buttons still work unchanged. --- webui/static/settings.js | 31 +++++++++++++++++++++++++++++++ webui/static/style.css | 6 ++++++ 2 files changed, 37 insertions(+) diff --git a/webui/static/settings.js b/webui/static/settings.js index 533d66b8..7c82f392 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -699,6 +699,21 @@ function buildHybridSourceList() { `; + // Real drag-to-reorder (the help text promised it; previously only the + // arrow buttons worked — item.draggable was set with no handlers). + item.addEventListener('dragstart', (e) => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', srcId); + item.classList.add('dragging'); + }); + item.addEventListener('dragend', () => item.classList.remove('dragging')); + item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }); + item.addEventListener('drop', (e) => { + e.preventDefault(); + const draggedId = e.dataTransfer.getData('text/plain'); + if (draggedId && draggedId !== srcId) _reorderHybridSource(draggedId, srcId); + }); + container.appendChild(item); }); @@ -723,6 +738,22 @@ function moveHybridSource(srcId, direction) { debouncedAutoSaveSettings(); } +function _reorderHybridSource(draggedId, targetId) { + // Move draggedId to just before targetId in the visual order, then rebuild + // the enabled subset + persist — same model moveHybridSource uses. + if (!_hybridVisualOrder) return; + const from = _hybridVisualOrder.indexOf(draggedId); + if (from < 0) return; + _hybridVisualOrder.splice(from, 1); + const to = _hybridVisualOrder.indexOf(targetId); + if (to < 0) { _hybridVisualOrder.splice(from, 0, draggedId); return; } // target gone — undo + _hybridVisualOrder.splice(to, 0, draggedId); + _hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false); + buildHybridSourceList(); + updateDownloadSourceUI(); + debouncedAutoSaveSettings(); +} + function toggleHybridSource(srcId, enabled) { _hybridSourceEnabled[srcId] = enabled; // Rebuild enabled order from visual order so priority matches position diff --git a/webui/static/style.css b/webui/static/style.css index bdf6f023..03987c26 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -55534,6 +55534,12 @@ tr.tag-diff-same { border-radius: 10px; transition: all 0.2s; user-select: none; + cursor: grab; +} +.hybrid-source-item.dragging { + opacity: 0.5; + cursor: grabbing; + border-color: rgb(var(--accent-light-rgb)); } .hybrid-source-item:hover { border-color: rgba(255, 255, 255, 0.12); From d2a167ff5fc02b03d38422ea1a0f821e0a4a2304 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 00:48:03 -0700 Subject: [PATCH 06/52] =?UTF-8?q?Profiles:=20review=20fixes=20=E2=80=94=20?= =?UTF-8?q?close=20two=20gating=20gaps=20+=20reject=20whitespace=20secrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial line-by-line review of the feature diff turned up: - /api/database/update/stop was NOT @admin_only while its sibling start_update was — a non-admin could abort a library scan. Gated. - /api/metadata-cache/evict was NOT gated while its clear siblings were. Gated. - validate_credential_payload now treats whitespace-only values as missing, so a blank-but-spacey secret can't be saved to fail confusingly later. Tests updated: both endpoints added to the admin-gating matrix; a whitespace-only validation case added. 42 credential/gating tests pass. Review also confirmed (no change needed): migration is idempotent + additive + O(1); encryption round-trips with a non-dict guard; no SQL injection; stale selections fall back to None safely; no secret ever returned to the browser; the hybrid-drag index math is correct in both directions; the new resolver is fully DORMANT (zero runtime callers) so existing client behaviour is untouched; and @admin_only is a no-op for single-profile installs (default profile = admin). --- core/credentials/store.py | 8 +++++++- tests/test_admin_gating.py | 2 ++ tests/test_service_credentials.py | 7 +++++++ web_server.py | 2 ++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/core/credentials/store.py b/core/credentials/store.py index 2d39fdf1..8cceb253 100644 --- a/core/credentials/store.py +++ b/core/credentials/store.py @@ -49,7 +49,13 @@ def validate_credential_payload(service: str, payload): return False, [] if not isinstance(payload, dict): return False, list(required) - missing = [k for k in required if not payload.get(k)] + + def _present(v): + # Whitespace-only strings count as missing — they'd otherwise save a + # blank secret that fails confusingly at the real service later. + return bool(v.strip()) if isinstance(v, str) else bool(v) + + missing = [k for k in required if not _present(payload.get(k))] return (not missing), missing diff --git a/tests/test_admin_gating.py b/tests/test_admin_gating.py index 6ab66818..06dc1adf 100644 --- a/tests/test_admin_gating.py +++ b/tests/test_admin_gating.py @@ -34,12 +34,14 @@ GATED = [ ('DELETE', '/api/library/album/123'), ('POST', '/api/library/tracks/delete-batch'), ('POST', '/api/database/update'), + ('POST', '/api/database/update/stop'), ('POST', '/api/database/backup'), ('DELETE', '/api/database/backups/x.db'), ('POST', '/api/database/backups/x.db/restore'), ('POST', '/api/database/maintenance/vacuum'), ('DELETE', '/api/metadata-cache/clear'), ('DELETE', '/api/metadata-cache/clear-musicbrainz'), + ('POST', '/api/metadata-cache/evict'), ] diff --git a/tests/test_service_credentials.py b/tests/test_service_credentials.py index aec802ec..64deae38 100644 --- a/tests/test_service_credentials.py +++ b/tests/test_service_credentials.py @@ -51,6 +51,13 @@ def test_validate_treats_empty_string_as_missing(): assert not ok and missing == ['username'] +def test_validate_treats_whitespace_only_as_missing(): + # A blank-but-spacey secret must be rejected, not saved to fail later. + ok, missing = validate_credential_payload('navidrome', + {'base_url': 'http://x', 'username': ' ', 'password': 'p'}) + assert not ok and missing == ['username'] + + # ── pure: active-set selection (stale-safe) ────────────────────────────────── def test_pick_active_credential_match_and_misses(): diff --git a/web_server.py b/web_server.py index 805ed158..8376c9b5 100644 --- a/web_server.py +++ b/web_server.py @@ -16105,6 +16105,7 @@ def get_database_update_status(): return jsonify(db_update_state) @app.route('/api/database/update/stop', methods=['POST']) +@admin_only def stop_database_update(): """Endpoint to stop the current database update.""" global db_update_worker @@ -16586,6 +16587,7 @@ def metadata_cache_clear(): return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/metadata-cache/evict', methods=['POST']) +@admin_only def metadata_cache_evict(): """Evict expired entries from the metadata cache.""" try: From 156c890de758472169a70b6dbf39a41dc5858045 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 08:58:43 -0700 Subject: [PATCH 07/52] Profiles Phase 1+2 (UI): admin Connected Accounts manager + quick-switch modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/test_script_split_integrity.py | 2 +- webui/index.html | 12 +- webui/static/credential-sets.js | 230 +++++++++++++++++++++++++++ webui/static/settings.js | 3 + webui/static/style.css | 91 +++++++++++ 5 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 webui/static/credential-sets.js diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index c299d14f..7224abdd 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -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 + diff --git a/webui/static/credential-sets.js b/webui/static/credential-sets.js new file mode 100644 index 00000000..deb09e9c --- /dev/null +++ b/webui/static/credential-sets.js @@ -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 = '
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 */ } +} diff --git a/webui/static/settings.js b/webui/static/settings.js index 7c82f392..9cc9e341 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -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'); diff --git a/webui/static/style.css b/webui/static/style.css index 03987c26..32c7cf4f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; +} From 22202104ef29d07285f89f20e2ef00d314647eb2 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 09:45:32 -0700 Subject: [PATCH 08/52] =?UTF-8?q?Profiles:=20redesign=20the=20Service=20St?= =?UTF-8?q?atus=20modal=20=E2=80=94=20active=20source/server/download=20sw?= =?UTF-8?q?itcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the basic credential-pill quick-switch with a Manage-Workers-styled modal (topbar + left rail + panel, entrance animation, brand-logo cards). - Sidebar Service Status: whole panel opens the modal; clicking the Metadata / Media Server / Download rows deep-links straight to that tab. Removed the "switch ▸" hover text. - Three tabs: Metadata (source logo cards, unavailable ones dimmed), Server (Plex/Jellyfin/Navidrome/SoulSync logos), Download (Single⇄Hybrid segmented toggle; Hybrid shows a draggable priority list). Logos reuse SOURCE_LABELS + HYBRID_SOURCES; active card gets an accent ring + check. - Admin writes the GLOBAL active source/server/download (reuses the same setters + client reloads as the Settings save, so changes take effect immediately). Non-admins see it read-only (editable=false) — the per-profile override is the next layer. Backend: GET /api/profiles/me/active-sources (any profile; reports editable), POST /api/profiles/active-sources (@admin_only; validates against the allowed metadata/server/download lists, applies + reloads). New service-switch.js (registered + in the integrity registry); old modal removed from credential-sets.js (admin Connected Accounts manager stays). Tests: 14 endpoint tests — read shape, admin sets metadata/hybrid+order (reflected), bad-value 400s, non-admin read-only + 403 on write. 64 integrity tests pass; real-app smoke confirms render + deep-links + the full set/reflect cycle. --- tests/test_credentials_endpoints.py | 32 ++++ tests/test_script_split_integrity.py | 2 +- web_server.py | 120 ++++++++++++ webui/index.html | 11 +- webui/static/credential-sets.js | 80 -------- webui/static/service-switch.js | 276 +++++++++++++++++++++++++++ webui/static/style.css | 100 ++++++++++ 7 files changed, 535 insertions(+), 86 deletions(-) create mode 100644 webui/static/service-switch.js diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index e4399115..df2d9c18 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -149,3 +149,35 @@ def test_select_rejects_wrong_service_or_missing_set(client): # Unsupported service rejected. assert client.post('/api/profiles/me/services/select', json={'service': 'itunes', 'credential_id': None}).status_code == 400 + + +# ── Quick-switch: active source/server/download (admin=global, non-admin read-only) ── + +def test_active_sources_read_shape(client): + a = client.get('/api/profiles/me/active-sources').get_json() + assert a['success'] and a['editable'] is True # default session = admin + assert a['metadata']['active'] and len(a['metadata']['options']) == 6 + assert len(a['server']['options']) == 4 + assert 'mode' in a['download'] and isinstance(a['download']['hybrid_order'], list) + + +def test_admin_sets_global_active_sources(client): + assert client.post('/api/profiles/active-sources', json={'metadata_source': 'itunes'}).get_json()['success'] + assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'itunes' + # hybrid + order round-trips + client.post('/api/profiles/active-sources', json={'download_mode': 'hybrid', 'hybrid_order': ['hifi', 'soulseek']}) + dl = client.get('/api/profiles/me/active-sources').get_json()['download'] + assert dl['mode'] == 'hybrid' and dl['hybrid_order'] == ['hifi', 'soulseek'] + + +def test_active_sources_rejects_bad_values(client): + assert client.post('/api/profiles/active-sources', json={'metadata_source': 'nope'}).status_code == 400 + assert client.post('/api/profiles/active-sources', json={'media_server': 'nope'}).status_code == 400 + assert client.post('/api/profiles/active-sources', json={'download_mode': 'nope'}).status_code == 400 + + +def test_active_sources_nonadmin_readonly_and_blocked(client, nonadmin_profile): + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + assert client.get('/api/profiles/me/active-sources').get_json()['editable'] is False + assert client.post('/api/profiles/active-sources', json={'metadata_source': 'deezer'}).status_code == 403 diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index 7224abdd..ba08b4df 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -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", "credential-sets.js"} + "watchlist-history.js", "credential-sets.js", "service-switch.js"} # Pre-existing duplicate helper functions that lived in the original monolith. # In a plain + diff --git a/webui/static/credential-sets.js b/webui/static/credential-sets.js index deb09e9c..2745df7d 100644 --- a/webui/static/credential-sets.js +++ b/webui/static/credential-sets.js @@ -148,83 +148,3 @@ async function deleteCredentialSet(id, serviceName) { } } 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 */ } -} diff --git a/webui/static/service-switch.js b/webui/static/service-switch.js new file mode 100644 index 00000000..9a33bfb0 --- /dev/null +++ b/webui/static/service-switch.js @@ -0,0 +1,276 @@ +/* + * Quick-switch modal — active Metadata / Server / Download source selection. + * Opens from the sidebar Service Status panel; styled after the Manage Workers + * hub (topbar + rail + panel, brand-logo cards). + * + * Admin writes the GLOBAL active source/server/download (same as Settings). + * Non-admins see it read-only for now (per-profile override is a later layer): + * the backend reports `editable`, and the UI disables changes when false. + * + * Backend: GET /api/profiles/me/active-sources, POST /api/profiles/active-sources. + */ + +const _SS_TABS = [ + { id: 'metadata', name: 'Metadata', emoji: '🎼' }, + { id: 'server', name: 'Server', emoji: '🖥️' }, + { id: 'download', name: 'Download', emoji: '⬇️' }, +]; + +// Brand logos. Metadata pulls from SOURCE_LABELS (shared-helpers.js) when +// available; server + download have their own small maps. +const _SS_SERVER_INFO = { + plex: { name: 'Plex', logo: 'https://www.plex.tv/wp-content/themes/plex/assets/img/plex-logo.svg' }, + jellyfin: { name: 'Jellyfin', logo: 'https://jellyfin.org/images/logo.svg' }, + navidrome: { name: 'Navidrome', logo: 'https://www.navidrome.org/images/navidrome-logo-200x150.png' }, + soulsync: { name: 'SoulSync', logo: '/static/trans2.png' }, +}; +const _SS_META_FALLBACK = { + spotify_free: { text: 'Spotify (no auth)', icon: '🆓', logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png' }, +}; + +let _ssState = { tab: 'metadata', data: null }; + +function _ssEsc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +function _ssMetaInfo(id) { + if (typeof SOURCE_LABELS !== 'undefined' && SOURCE_LABELS[id]) return SOURCE_LABELS[id]; + if (_SS_META_FALLBACK[id]) return _SS_META_FALLBACK[id]; + return { text: id, icon: '🎵' }; +} + +function _ssDownloadInfo(id) { + if (typeof HYBRID_SOURCES !== 'undefined') { + const h = HYBRID_SOURCES.find(s => s.id === id); + if (h) return { name: h.name, logo: h.icon, emoji: h.emoji }; + } + return { name: id, emoji: '⬇️' }; +} + +function openServiceSwitchModal(tab) { + _ssState.tab = _SS_TABS.some(t => t.id === tab) ? tab : 'metadata'; + let overlay = document.getElementById('service-switch-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'service-switch-overlay'; + overlay.className = 'modal-overlay ss-overlay hidden'; + overlay.onclick = (e) => { if (e.target === overlay) closeServiceSwitchModal(); }; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + } + overlay.classList.remove('hidden', 'ss-closing'); + const modal = overlay.querySelector('.ss-modal'); + if (modal) { modal.classList.remove('ss-in'); void modal.offsetWidth; modal.classList.add('ss-in'); } + document.addEventListener('keydown', _ssOnKeydown); + _ssLoad(); +} + +function closeServiceSwitchModal() { + const o = document.getElementById('service-switch-overlay'); + if (o) o.classList.add('hidden'); + document.removeEventListener('keydown', _ssOnKeydown); +} + +function _ssOnKeydown(e) { if (e.key === 'Escape') closeServiceSwitchModal(); } + +async function _ssLoad() { + _ssRenderRail(); + const panel = document.getElementById('ss-panel'); + if (panel) panel.innerHTML = '
Loading…
'; + try { + const res = await fetch('/api/profiles/me/active-sources'); + _ssState.data = await res.json(); + } catch (e) { + _ssState.data = null; + } + _ssRenderPanel(); +} + +function _ssRenderRail() { + const rail = document.getElementById('ss-rail'); + if (!rail) return; + rail.innerHTML = _SS_TABS.map(t => ` + `).join(''); +} + +function switchServiceSwitchTab(tab) { + _ssState.tab = tab; + _ssRenderRail(); + _ssRenderPanel(); +} + +function _ssCard({ logo, emoji, label, active, available, onclick, badge }) { + const dim = available === false ? ' ss-card--locked' : ''; + const act = active ? ' active' : ''; + const media = logo + ? `` + : `${emoji || '🎵'}`; + return ` + `; +} + +function _ssRenderPanel() { + const panel = document.getElementById('ss-panel'); + const d = _ssState.data; + if (!panel) return; + if (!d || !d.success) { panel.innerHTML = '
Could not load active sources.
'; return; } + const editable = !!d.editable; + const sub = document.getElementById('ss-topbar-sub'); + if (sub) sub.textContent = editable + ? 'What this profile uses for metadata, library, and downloads' + : 'Set by the admin — view only for now'; + + if (_ssState.tab === 'metadata') { + const cards = d.metadata.options.map(o => { + const info = _ssMetaInfo(o.id); + return _ssCard({ + logo: info.logo, emoji: info.icon, label: info.text || o.id, + active: d.metadata.active === o.id, available: o.available, + onclick: (editable && o.available) ? `setActiveSource('metadata','${o.id}')` : null, + }); + }).join(''); + panel.innerHTML = `
Metadata source
${cards}
`; + } else if (_ssState.tab === 'server') { + const cards = d.server.options.map(o => { + const info = _SS_SERVER_INFO[o.id] || { name: o.id }; + return _ssCard({ + logo: info.logo, emoji: '🖥️', label: info.name, + active: d.server.active === o.id, available: o.available, + onclick: (editable && o.available) ? `setActiveSource('server','${o.id}')` : null, + }); + }).join(''); + panel.innerHTML = `
Media server
${cards}
`; + } else { + _ssRenderDownloadPanel(panel, d, editable); + } +} + +function _ssRenderDownloadPanel(panel, d, editable) { + const isHybrid = d.download.mode === 'hybrid'; + const toggle = ` +
+ + +
`; + + let body; + if (isHybrid) { + const order = (d.download.hybrid_order && d.download.hybrid_order.length) + ? d.download.hybrid_order + : d.download.options.map(o => o.id); + body = `
Drag to set priority — SoulSync tries each in order.
+
` + + order.map((id, i) => { + const info = _ssDownloadInfo(id); + return `
+ ${i + 1} + ${info.logo ? `` : `${info.emoji}`} + ${_ssEsc(info.name)} +
`; + }).join('') + `
`; + } else { + const cards = d.download.options.map(o => { + const info = _ssDownloadInfo(o.id); + return _ssCard({ + logo: info.logo, emoji: info.emoji, label: info.name, + active: d.download.mode === o.id, available: true, + onclick: editable ? `setActiveSource('download','${o.id}')` : null, + }); + }).join(''); + body = `
${cards}
`; + } + panel.innerHTML = `
Download source
${toggle}${body}`; + if (isHybrid && editable) _ssWireHybridDrag(); +} + +function _ssWireHybridDrag() { + const list = document.getElementById('ss-hybrid-list'); + if (!list) return; + list.querySelectorAll('.ss-hybrid-item').forEach(item => { + item.addEventListener('dragstart', (e) => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', item.dataset.src); + item.classList.add('dragging'); + }); + item.addEventListener('dragend', () => item.classList.remove('dragging')); + item.addEventListener('dragover', (e) => { e.preventDefault(); }); + item.addEventListener('drop', (e) => { + e.preventDefault(); + const dragged = e.dataTransfer.getData('text/plain'); + if (dragged && dragged !== item.dataset.src) _ssReorderHybrid(dragged, item.dataset.src); + }); + }); +} + +function _ssReorderHybrid(draggedId, targetId) { + const order = (_ssState.data.download.hybrid_order && _ssState.data.download.hybrid_order.length) + ? _ssState.data.download.hybrid_order.slice() + : _ssState.data.download.options.map(o => o.id); + const from = order.indexOf(draggedId); + if (from < 0) return; + order.splice(from, 1); + const to = order.indexOf(targetId); + order.splice(to < 0 ? order.length : to, 0, draggedId); + _ssSave({ hybrid_order: order }); +} + +async function setActiveSource(kind, id) { + const key = kind === 'metadata' ? 'metadata_source' : kind === 'server' ? 'media_server' : 'download_mode'; + await _ssSave({ [key]: id }); +} + +async function setDownloadMode(which) { + if (which === 'hybrid') { + await _ssSave({ download_mode: 'hybrid' }); + } else { + // Switch to a single source — keep the current single choice if it was + // already single, else default to the first option. + const d = _ssState.data; + const cur = d.download.mode; + const single = (cur && cur !== 'hybrid') ? cur : (d.download.options[0] && d.download.options[0].id) || 'soulseek'; + await _ssSave({ download_mode: single }); + } +} + +async function _ssSave(patch) { + try { + const res = await fetch('/api/profiles/active-sources', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + const data = await res.json(); + if (!data.success) { + if (typeof showToast === 'function') showToast(data.error || 'Change failed', 'error'); + return; + } + if (typeof showToast === 'function') showToast('Updated', 'success'); + await _ssLoad(); // re-read + re-render with the new active state + if (typeof fetchAndUpdateServiceStatus === 'function') fetchAndUpdateServiceStatus(); + } catch (e) { + if (typeof showToast === 'function') showToast('Change failed', 'error'); + } +} diff --git a/webui/static/style.css b/webui/static/style.css index 32c7cf4f..e205e946 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -67612,3 +67612,103 @@ body.em-scroll-lock { overflow: hidden; } background: rgb(var(--accent-light-rgb)); color: #0a0a0a; border-color: rgb(var(--accent-light-rgb)); font-weight: 600; } + +/* ── Quick-switch modal (active Metadata / Server / Download) — Manage-Workers style ── */ +.ss-overlay { display: flex; align-items: center; justify-content: center; } +.ss-modal { + background: linear-gradient(160deg, #181a21 0%, #121319 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 18px; width: min(760px, 94vw); max-height: 86vh; + display: flex; flex-direction: column; overflow: hidden; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.65); +} +.ss-modal.ss-in { animation: ss-pop 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2); } +@keyframes ss-pop { from { opacity: 0; transform: translateY(14px) scale(0.97); } to { opacity: 1; transform: none; } } + +.ss-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); +} +.ss-topbar-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; } +.ss-topbar-logo { width: 36px; height: 36px; object-fit: contain; } +.ss-topbar-titles { flex: 1; } +.ss-topbar-title { margin: 0; font-size: 1.2rem; } +.ss-topbar-sub { color: rgba(255, 255, 255, 0.5); font-size: 0.82rem; margin-top: 2px; } +.ss-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; +} +.ss-icon-btn:hover { background: rgba(255, 255, 255, 0.14); } + +.ss-body { display: flex; min-height: 320px; max-height: calc(86vh - 78px); } +.ss-rail { + width: 168px; flex-shrink: 0; padding: 14px 10px; display: flex; flex-direction: column; gap: 6px; + border-right: 1px solid rgba(255, 255, 255, 0.06); background: rgba(0, 0, 0, 0.18); +} +.ss-tab { + display: flex; align-items: center; gap: 10px; padding: 11px 14px; + background: transparent; border: none; border-radius: 10px; cursor: pointer; + color: rgba(255, 255, 255, 0.65); font-size: 0.92rem; text-align: left; transition: all 0.15s; +} +.ss-tab:hover { background: rgba(255, 255, 255, 0.05); color: #fff; } +.ss-tab.active { + background: rgba(var(--accent-light-rgb), 0.16); + color: rgb(var(--accent-light-rgb)); font-weight: 600; + box-shadow: inset 2px 0 0 rgb(var(--accent-light-rgb)); +} +.ss-tab-emoji { font-size: 1.1rem; } + +.ss-panel { flex: 1; padding: 20px 22px; overflow-y: auto; } +.ss-section-title { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(255, 255, 255, 0.45); margin-bottom: 14px; } +.ss-hint { color: rgba(255, 255, 255, 0.5); font-size: 0.82rem; margin-bottom: 12px; } +.ss-empty { color: rgba(255, 255, 255, 0.4); font-style: italic; padding: 30px 0; text-align: center; } + +.ss-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; } +.ss-card { + position: relative; display: flex; flex-direction: column; align-items: center; gap: 10px; + padding: 18px 12px; background: rgba(255, 255, 255, 0.035); + border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 14px; + cursor: pointer; transition: all 0.16s; color: #fff; +} +.ss-card:hover:not([disabled]) { background: rgba(255, 255, 255, 0.07); transform: translateY(-2px); border-color: rgba(var(--accent-light-rgb), 0.4); } +.ss-card.active { + border-color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-light-rgb), 0.12); + box-shadow: 0 0 0 1px rgb(var(--accent-light-rgb)), 0 8px 24px rgba(var(--accent-light-rgb), 0.18); +} +.ss-card--locked { opacity: 0.4; cursor: not-allowed; } +.ss-card[disabled] { cursor: default; } +.ss-card-logo { width: 42px; height: 42px; object-fit: contain; } +.ss-card-emoji { font-size: 2rem; line-height: 1; } +.ss-card-label { font-size: 0.85rem; text-align: center; } +.ss-card-badge { + position: absolute; top: 6px; left: 6px; font-size: 0.62rem; padding: 2px 6px; + background: rgba(0, 0, 0, 0.5); border-radius: 6px; color: rgba(255, 255, 255, 0.7); +} +.ss-card-check { + position: absolute; top: 6px; right: 8px; color: rgb(var(--accent-light-rgb)); font-weight: 700; +} + +.ss-seg { display: inline-flex; background: rgba(0, 0, 0, 0.25); border-radius: 10px; padding: 4px; margin-bottom: 16px; } +.ss-seg-btn { + border: none; background: transparent; color: rgba(255, 255, 255, 0.6); + padding: 7px 18px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; transition: all 0.15s; +} +.ss-seg-btn.active { background: rgb(var(--accent-light-rgb)); color: #0a0a0a; font-weight: 600; } +.ss-seg-btn[disabled] { cursor: default; } + +.ss-hybrid-list { display: flex; flex-direction: column; gap: 8px; } +.ss-hybrid-item { + display: flex; align-items: center; gap: 12px; padding: 10px 14px; + background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; cursor: grab; +} +.ss-hybrid-item.dragging { opacity: 0.5; cursor: grabbing; border-color: rgb(var(--accent-light-rgb)); } +.ss-hybrid-rank { + width: 22px; height: 22px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; + background: rgba(var(--accent-light-rgb), 0.2); color: rgb(var(--accent-light-rgb)); + border-radius: 50%; font-size: 0.72rem; font-weight: 700; +} +.ss-hybrid-logo { width: 26px; height: 26px; object-fit: contain; } +.ss-hybrid-name { font-size: 0.9rem; } From cd59d7553191e7de5eb192660b9d8146ed90b53d Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 09:58:47 -0700 Subject: [PATCH 09/52] Profiles: richer Service Status modal + surface configured-vs-effective source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual rework toward the Manage Workers feel: - Cards are now circular brand-logo discs on white, with each service's brand color (Spotify green, Deezer purple, Plex gold, …) driving the logo ring + active glow/gradient + hover lift. Replaces the flat emoji tiles. - The left rail is alive: each tab shows its category + the CURRENT active choice's logo and label (e.g. "Metadata · Deezer"), with the active tab in a brand-tinted gradient + accent bar — mirroring the worker rows. Correctness fix (answers "modal says spotify, settings says spotify (no auth)"): the modal read the RAW configured source, but the rest of the app shows the EFFECTIVE one. get_primary_source() silently downgrades a configured 'spotify' to the default (deezer) when Spotify isn't authenticated — so configured and effective diverge. The endpoint now returns `effective` alongside `active`, and the Metadata panel shows a note ("Configured source isn't connected — actually using Deezer right now") whenever they differ. Settings was never broken; the modal just wasn't showing the resolved source. 78 tests pass (integrity + endpoints); smoke confirms configured spotify → effective deezer surfaces, spotify_free stays itself. --- web_server.py | 6 +++ webui/static/service-switch.js | 70 ++++++++++++++++++++++++++++------ webui/static/style.css | 67 ++++++++++++++++++++------------ 3 files changed, 107 insertions(+), 36 deletions(-) diff --git a/web_server.py b/web_server.py index 6fc0780e..8e4d3eea 100644 --- a/web_server.py +++ b/web_server.py @@ -25645,7 +25645,13 @@ def get_active_sources(): 'success': True, 'editable': get_current_profile_id() == 1, # admin writes the global default 'metadata': { + # `active` = the configured choice (what the user picked / edits). + # `effective` = what's actually used after auth/availability + # fallback (e.g. configured 'spotify' but not authenticated → + # the app falls back). Surfacing both stops the modal disagreeing + # with the sidebar/Settings status. 'active': config_manager.get('metadata.fallback_source', 'deezer') or 'deezer', + 'effective': _get_metadata_fallback_source(), 'options': [{'id': s, 'available': _qs_metadata_available(s)} for s in _QS_METADATA_SOURCES], }, 'server': { diff --git a/webui/static/service-switch.js b/webui/static/service-switch.js index 9a33bfb0..f046f4a2 100644 --- a/webui/static/service-switch.js +++ b/webui/static/service-switch.js @@ -27,6 +27,15 @@ const _SS_SERVER_INFO = { const _SS_META_FALLBACK = { spotify_free: { text: 'Spotify (no auth)', icon: '🆓', logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png' }, }; +// Brand colors drive each card's logo ring + active glow (the Manage-Workers feel). +const _SS_BRAND = { + spotify: '#1db954', spotify_free: '#1db954', itunes: '#fc5c7d', deezer: '#a238ff', + discogs: '#ff5500', musicbrainz: '#ba478f', amazon: '#ff9900', + plex: '#e5a00d', jellyfin: '#aa5cc3', navidrome: '#3b6cf6', soulsync: '#7c5cff', + soulseek: '#22a7f0', youtube: '#ff0000', tidal: '#00cfe8', qobuz: '#0a6e9e', + hifi: '#16c79a', torrent: '#8a2be2', usenet: '#e67e22', +}; +function _ssBrand(id) { return _SS_BRAND[id] || 'var(--accent-light-rgb-hex, #7c5cff)'; } let _ssState = { tab: 'metadata', data: null }; @@ -99,17 +108,48 @@ async function _ssLoad() { } catch (e) { _ssState.data = null; } + _ssRenderRail(); // re-render now that we know each tab's active choice _ssRenderPanel(); } +function _ssRailCurrent(tabId) { + // The active choice for a tab → {logo/emoji, label, brand} for the rail chip. + const d = _ssState.data; + if (!d || !d.success) return null; + if (tabId === 'metadata') { + const id = d.metadata.active; const info = _ssMetaInfo(id); + return { logo: info.logo, emoji: info.icon, label: info.text || id, brand: _ssBrand(id) }; + } + if (tabId === 'server') { + const id = d.server.active; const info = _SS_SERVER_INFO[id] || { name: id }; + return { logo: info.logo, emoji: '🖥️', label: info.name, brand: _ssBrand(id) }; + } + const id = d.download.mode; + if (id === 'hybrid') return { emoji: '🔀', label: 'Hybrid', brand: 'var(--accent-light-rgb-hex,#7c5cff)' }; + const info = _ssDownloadInfo(id); + return { logo: info.logo, emoji: info.emoji, label: info.name, brand: _ssBrand(id) }; +} + function _ssRenderRail() { const rail = document.getElementById('ss-rail'); if (!rail) return; - rail.innerHTML = _SS_TABS.map(t => ` - `).join(''); + rail.innerHTML = _SS_TABS.map(t => { + const cur = _ssRailCurrent(t.id); + const media = cur + ? (cur.logo + ? `` + : `${cur.emoji}`) + : `${t.emoji}`; + return ` + `; + }).join(''); } function switchServiceSwitchTab(tab) { @@ -118,15 +158,15 @@ function switchServiceSwitchTab(tab) { _ssRenderPanel(); } -function _ssCard({ logo, emoji, label, active, available, onclick, badge }) { +function _ssCard({ logo, emoji, label, active, available, onclick, badge, brand }) { const dim = available === false ? ' ss-card--locked' : ''; const act = active ? ' active' : ''; const media = logo ? `` : `${emoji || '🎵'}`; return ` - `; } +const _SS_TAB_BLURB = { + metadata: 'Where artist, album & track details come from.', + server: 'The library backend SoulSync reads and writes.', + download: 'Where SoulSync grabs tracks you don\'t have yet.', +}; + +function _ssHero(kind) { + const cur = _ssRailCurrent(kind); + if (!cur) return ''; + const media = cur.logo + ? `` + : `${cur.emoji}`; + const eyebrow = kind === 'metadata' ? 'Active metadata source' + : kind === 'server' ? 'Active media server' : 'Active download source'; + return ` +
+
${media}
+
+
${eyebrow}
+
${_ssEsc(cur.label)}
+
${_SS_TAB_BLURB[kind] || ''}
+
+ Active +
`; +} + function _ssRenderPanel() { const panel = document.getElementById('ss-panel'); const d = _ssState.data; if (!panel) return; if (!d || !d.success) { panel.innerHTML = '
Could not load active sources.
'; return; } const editable = !!d.editable; + panel.style.setProperty('--ss-brand', (_ssRailCurrent(_ssState.tab) || {}).brand || '#7c5cff'); const sub = document.getElementById('ss-topbar-sub'); if (sub) sub.textContent = editable ? 'What this profile uses for metadata, library, and downloads' @@ -199,7 +226,7 @@ function _ssRenderPanel() { const note = (eff && eff !== d.metadata.active) ? `
Configured source isn't connected — actually using ${_ssEsc((_ssMetaInfo(eff).text) || eff)} right now.
` : ''; - panel.innerHTML = `
Metadata source
${note}
${cards}
`; + panel.innerHTML = `${_ssHero('metadata')}
Choose source
${note}
${cards}
`; } else if (_ssState.tab === 'server') { const cards = d.server.options.map(o => { const info = _SS_SERVER_INFO[o.id] || { name: o.id }; @@ -209,7 +236,7 @@ function _ssRenderPanel() { onclick: (editable && o.available) ? `setActiveSource('server','${o.id}')` : null, }); }).join(''); - panel.innerHTML = `
Media server
${cards}
`; + panel.innerHTML = `${_ssHero('server')}
Choose server
${cards}
`; } else { _ssRenderDownloadPanel(panel, d, editable); } @@ -249,7 +276,7 @@ function _ssRenderDownloadPanel(panel, d, editable) { }).join(''); body = `
${cards}
`; } - panel.innerHTML = `
Download source
${toggle}${body}`; + panel.innerHTML = `${_ssHero('download')}
Choose source
${toggle}${body}`; if (isHybrid && editable) _ssWireHybridDrag(); } diff --git a/webui/static/style.css b/webui/static/style.css index af9e44ff..42b3e5bb 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -67668,7 +67668,38 @@ body.em-scroll-lock { overflow: hidden; } .ss-tab.active .ss-tab-cat { color: color-mix(in srgb, var(--ss-brand) 75%, white); } .ss-tab-cur { font-size: 0.92rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.ss-panel { flex: 1; padding: 22px 24px; overflow-y: auto; } +.ss-panel { + flex: 1; padding: 22px 24px; overflow-y: auto; + background: + radial-gradient(120% 80% at 100% 0%, color-mix(in srgb, var(--ss-brand) 9%, transparent), transparent 60%), + radial-gradient(90% 60% at 0% 100%, rgba(255,255,255,0.02), transparent 70%); +} +.ss-hero { + position: relative; display: flex; align-items: center; gap: 16px; padding: 16px 18px; margin-bottom: 20px; + border-radius: 16px; overflow: hidden; + background: linear-gradient(120deg, color-mix(in srgb, var(--ss-brand) 24%, transparent), rgba(255,255,255,0.025)); + border: 1px solid color-mix(in srgb, var(--ss-brand) 40%, transparent); +} +.ss-hero::after { + content: ''; position: absolute; right: -40px; top: -60px; width: 180px; height: 180px; border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--ss-brand) 40%, transparent), transparent 70%); + pointer-events: none; +} +.ss-hero-disc { + width: 60px; height: 60px; flex-shrink: 0; border-radius: 50%; background: #fff; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 0 0 3px var(--ss-brand), 0 0 26px color-mix(in srgb, var(--ss-brand) 55%, transparent); +} +.ss-hero-logo { width: 36px; height: 36px; object-fit: contain; } +.ss-hero-emoji { font-size: 1.8rem; } +.ss-hero-info { flex: 1; min-width: 0; } +.ss-hero-eyebrow { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; color: color-mix(in srgb, var(--ss-brand) 70%, white); font-weight: 700; } +.ss-hero-name { font-size: 1.35rem; font-weight: 700; margin: 1px 0 3px; } +.ss-hero-sub { font-size: 0.82rem; color: rgba(255,255,255,0.55); } +.ss-hero-pill { + align-self: flex-start; font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; + padding: 4px 10px; border-radius: 999px; color: #0a0a0a; background: var(--ss-brand); +} .ss-section-title { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(255, 255, 255, 0.45); margin-bottom: 16px; } .ss-hint { color: rgba(255, 255, 255, 0.5); font-size: 0.82rem; margin-bottom: 12px; } .ss-empty { color: rgba(255, 255, 255, 0.4); font-style: italic; padding: 30px 0; text-align: center; } From 22947794e06d6e1eca9509072287b7a30a870ec3 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 10:41:09 -0700 Subject: [PATCH 11/52] Profiles: label the no-auth Spotify composite everywhere it's shown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the modal fix: the sidebar Service Status + dashboard service card also mislabeled "Spotify (no auth)" as plain "Spotify". They read the status `source`, which came straight from metadata.fallback_source ('spotify') with no awareness of the metadata.spotify_free flag. - get_primary_source_status now reports a DISPLAY source of 'spotify_free' when fallback_source='spotify' + metadata.spotify_free is set (the raw 'spotify' is still used for the auth/connected checks), and treats the free path's availability as "connected" so the dot isn't falsely red on a no-auth setup. - getMetadataSourceLabel maps 'spotify_free' → "Spotify (no auth)"; the status presentation treats spotify_free as part of the Spotify family (session / rate-limit / cooldown display still work). Added a SOURCE_LABELS entry. - testDashboardConnection normalizes spotify_free → spotify (the only logic consumer of the source value — the dashboard Test button). Routing is unchanged (the real source stays 'spotify' + free flag); this is purely the display layer. Settings was always correct. 64 integrity tests pass; the 2 failing soundcloud tests are pre-existing (confirmed identical on a clean tree). --- core/metadata/registry.py | 19 ++++++++++++++++++- webui/static/settings.js | 3 +++ webui/static/shared-helpers.js | 17 +++++++++++++---- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/core/metadata/registry.py b/core/metadata/registry.py index 3299d55a..3aab4468 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -415,6 +415,14 @@ def get_primary_source_status( ) if source == "spotify": connected = bool(client and client.is_spotify_authenticated()) + # No-auth composite (fallback_source='spotify' + metadata.spotify_free): + # works without authentication, so treat the free path's availability + # as "connected" too. + if not connected and _get_config_value("metadata.spotify_free", False): + try: + connected = bool(client and client.is_spotify_metadata_available()) + except Exception: + connected = False elif source == "hydrabase": connected = bool(client and (client.is_connected() if hasattr(client, "is_connected") else client.is_authenticated())) elif client is not None and hasattr(client, "is_authenticated"): @@ -424,8 +432,17 @@ def get_primary_source_status( except Exception: connected = False + # Report the composite-aware source for DISPLAY: "Spotify (no auth)" is + # stored as fallback_source='spotify' + metadata.spotify_free=true. The raw + # 'spotify' is kept above for the connected/auth checks; consumers that + # label the source (sidebar, dashboard card) get 'spotify_free' so they + # stop mislabeling no-auth as plain Spotify. + display_source = source + if source == "spotify" and _get_config_value("metadata.spotify_free", False): + display_source = "spotify_free" + return { - "source": source, + "source": display_source, "connected": connected, "response_time": round((time.time() - started) * 1000, 1), } diff --git a/webui/static/settings.js b/webui/static/settings.js index 9cc9e341..99140053 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -3438,6 +3438,9 @@ async function revokeApiKey(keyId, label) { // Dashboard-specific test functions that create activity items async function testDashboardConnection(service) { + // 'spotify_free' is a display-only label for the no-auth composite; the real + // service to test is 'spotify'. + if (service === 'spotify_free') service = 'spotify'; try { showLoadingOverlay(`Testing ${service} service...`); diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 5e1c1e62..f5a0b958 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -42,6 +42,11 @@ const SOURCE_LABELS = { logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify', }, + spotify_free: { + text: 'Spotify (no auth)', icon: '🎵', + logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', + tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify', + }, itunes: { text: 'Apple Music', icon: '🍎', logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png', @@ -3633,6 +3638,7 @@ function getMetadataSourceLabel(source) { if (source === 'hydrabase') return 'Hydrabase'; if (source === 'itunes') return 'iTunes'; if (source === 'musicbrainz') return 'MusicBrainz'; + if (source === 'spotify_free') return 'Spotify (no auth)'; if (source === 'spotify') return 'Spotify'; return 'Unmapped'; } @@ -3641,9 +3647,12 @@ function getMetadataSourcePresentation(metadataStatus, spotifyStatus) { const source = metadataStatus?.source; const sourceLabel = getMetadataSourceLabel(source); const connected = metadataStatus?.connected === true; - const sessionActive = spotifyStatus?.authenticated === true || (source === 'spotify' && connected); - const rateLimited = !!(source === 'spotify' && spotifyStatus?.rate_limited && spotifyStatus?.rate_limit); - const cooldown = !!(source === 'spotify' && spotifyStatus?.post_ban_cooldown > 0); + // 'spotify_free' (the no-auth composite) is part of the Spotify family for + // session/rate-limit/cooldown display. + const spotifyFamily = (source === 'spotify' || source === 'spotify_free'); + const sessionActive = spotifyStatus?.authenticated === true || (spotifyFamily && connected); + const rateLimited = !!(spotifyFamily && spotifyStatus?.rate_limited && spotifyStatus?.rate_limit); + const cooldown = !!(spotifyFamily && spotifyStatus?.post_ban_cooldown > 0); if (rateLimited) { const remaining = spotifyStatus.rate_limit?.remaining_seconds || 0; @@ -3670,7 +3679,7 @@ function getMetadataSourcePresentation(metadataStatus, spotifyStatus) { if (source) { return { statusClass: connected ? 'connected' : 'disconnected', - statusText: connected ? (source === 'spotify' ? `Connected (${metadataStatus?.response_time}ms)` : sourceLabel) : 'Disconnected', + statusText: connected ? (spotifyFamily ? `Connected (${metadataStatus?.response_time}ms)` : sourceLabel) : 'Disconnected', dotClass: connected ? 'connected' : 'disconnected', dotTitle: connected ? sourceLabel : 'Disconnected', sessionActive From d018c19cb70d2585b9ba042081cd26ec491dc1e4 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 10:55:29 -0700 Subject: [PATCH 12/52] Profiles: fix server logos in the quick-switch modal (+ Settings Jellyfin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal's server logos were the odd ones out — Jellyfin used the wide jellyfin.org wordmark and Navidrome a stretched navidrome.org image. Switched the modal's _SS_SERVER_INFO (drives both the rail and the option grid) to clean square icons: Plex's plex-logo.svg, Navidrome's tweakers.net icon, and the homarr-labs Jellyfin PNG. Also swapped the Settings page Jellyfin toggle from the jellyfin.org wordmark to the same homarr-labs icon so they match. --- webui/index.html | 2 +- webui/static/service-switch.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/webui/index.html b/webui/index.html index 97a0e709..1a09f9f7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4300,7 +4300,7 @@ - `).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 */ } -} diff --git a/webui/static/settings.js b/webui/static/settings.js index 99140053..30288c68 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -1437,9 +1437,6 @@ 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'); diff --git a/webui/static/style.css b/webui/static/style.css index 760e886a..26f6e2e3 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -67522,97 +67522,6 @@ body.em-scroll-lock { overflow: hidden; } .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; -} - /* ── Quick-switch modal (active Metadata / Server / Download) — Manage-Workers style ── */ .ss-overlay { display: flex; align-items: center; justify-content: center; } .ss-modal { From e8bd9c801844469c0ca15faeaf715d40e08b7af8 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 12:21:17 -0700 Subject: [PATCH 17/52] Profiles: per-profile Spotify self-auth (shared app) + My Accounts modal + read wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/metadata/registry.py | 37 ++++-- tests/test_credentials_endpoints.py | 20 ++++ tests/test_profile_spotify_resolution.py | 29 +++++ tests/test_script_split_integrity.py | 2 +- web_server.py | 139 ++++++++++++++++------ webui/index.html | 4 + webui/static/my-accounts.js | 145 +++++++++++++++++++++++ webui/static/style.css | 45 +++++++ 8 files changed, 376 insertions(+), 45 deletions(-) create mode 100644 tests/test_profile_spotify_resolution.py create mode 100644 webui/static/my-accounts.js diff --git a/core/metadata/registry.py b/core/metadata/registry.py index 3aab4468..a5cc48d2 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -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_``). 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}", ) diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index 47d2817c..8c448e8d 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -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 diff --git a/tests/test_profile_spotify_resolution.py b/tests/test_profile_spotify_resolution.py new file mode 100644 index 00000000..ad753b5c --- /dev/null +++ b/tests/test_profile_spotify_resolution.py @@ -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_. +""" + +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 diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index e9c86e98..54c5adc5 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -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 + diff --git a/webui/static/my-accounts.js b/webui/static/my-accounts.js new file mode 100644 index 00000000..6201af03 --- /dev/null +++ b/webui/static/my-accounts.js @@ -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//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 => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = ` + `; + 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 = '
Loading…
'; + 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 = `Managed in Settings (app account)`; + } else if (connected) { + action = ` + + `; + } else { + action = ``; + } + return ` +
+ +
+
${_maEsc(svc.name)}
+
${connected ? 'Connected' : (adminNote ? '' : 'Not connected')}
+
+
${action}
+
`; + }).join(''); + body.innerHTML = rows || '
No services available.
'; +} + +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 */ } +} diff --git a/webui/static/style.css b/webui/static/style.css index 26f6e2e3..54829bd4 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; } From 7eaed1d5332fd4e12ea3b31d0a45888a40481efd Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 12:28:03 -0700 Subject: [PATCH 18/52] Profiles: per-profile Spotify builds for own-app creds OR a token cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review/regression fix: the shared-app gate I added wrongly required a token cache even for profiles that set their OWN app creds (legacy), breaking the per-profile caching tests. Now a per-profile client is built when the profile has its own app creds OR has connected (its own token cache) — otherwise it falls back to the global/admin client. Made the fallback-safety tests order-independent (monkeypatch get_spotify_client to a sentinel) since the real global client isn't a stable singleton across the suite. 10 metadata-cache + resolution tests pass together. --- core/metadata/registry.py | 18 ++++++++---- tests/test_profile_spotify_resolution.py | 35 ++++++++++++++---------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/core/metadata/registry.py b/core/metadata/registry.py index a5cc48d2..4e6f343d 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -203,17 +203,23 @@ def get_spotify_client_for_profile(profile_id: Optional[int] = None): except Exception: return get_spotify_client() + import os + cache_path = f"config/.spotify_cache_profile_{profile_id}" + own_app = bool(creds.get("client_id") and creds.get("client_secret")) + connected = os.path.exists(cache_path) + # Build a per-profile client when the profile has its OWN app creds (legacy) + # OR has connected via the shared app (its own token cache exists). A profile + # with neither uses the global/admin client — so background workers and + # unconnected users are unaffected. + if not own_app and not connected: + return get_spotify_client() + # 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): + if not client_id: return get_spotify_client() cache_key = _build_profile_spotify_cache_key( diff --git a/tests/test_profile_spotify_resolution.py b/tests/test_profile_spotify_resolution.py index ad753b5c..825e3a72 100644 --- a/tests/test_profile_spotify_resolution.py +++ b/tests/test_profile_spotify_resolution.py @@ -1,9 +1,14 @@ """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_. +non-admin profile that hasn't connected its own Spotify (no token cache, no own +app creds) — so background workers and existing users are unaffected. A +per-profile client only appears once that profile has its own app creds OR its +own .spotify_cache_profile_. + +We monkeypatch get_spotify_client to a sentinel so "fell back to global" is an +exact, order-independent identity check (the real global client isn't a stable +singleton across the suite). """ from __future__ import annotations @@ -13,17 +18,19 @@ 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_admin_and_none_use_global_client(monkeypatch): + sentinel = object() + monkeypatch.setattr(registry, "get_spotify_client", lambda *a, **k: sentinel) + registry.register_profile_spotify_credentials_provider(lambda pid: None) + assert registry.get_spotify_client_for_profile(1) is sentinel + assert registry.get_spotify_client_for_profile(None) is sentinel -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 +def test_unconnected_profile_falls_back_to_global(monkeypatch): + sentinel = object() + monkeypatch.setattr(registry, "get_spotify_client", lambda *a, **k: sentinel) + # No own app creds for this profile, and no token cache → must use global. + registry.register_profile_spotify_credentials_provider(lambda pid: None) 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 + assert not os.path.exists(f"config/.spotify_cache_profile_{pid}") + assert registry.get_spotify_client_for_profile(pid) is sentinel From 60b9fe10e976133ebb8b31399d318e69269a81a6 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 13:11:59 -0700 Subject: [PATCH 19/52] =?UTF-8?q?Profiles:=20per-profile=20Tidal=20self-au?= =?UTF-8?q?th=20(playlists)=20=E2=80=94=20with=20a=20safe=20token-save=20r?= =?UTF-8?q?edirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second service. Each profile connects its own Tidal; its playlist reads use that account, everything else stays global. The gotcha vs Spotify: TidalClient loads AND saves tokens to one global slot (tidal_tokens), so a naive per-profile client would clobber the admin's tokens on refresh. - get_tidal_client_for_profile builds a dedicated TidalClient seeded with the profile's tokens, refreshed via the shared/global app creds, and OVERRIDES its _save_tokens to persist to the PROFILE row — never the global slot. Admin (profile 1) + unconnected profiles use the global client unchanged. Cached per profile + evicted on (dis)connect. - DB: set_profile_tidal_tokens / get_profile_tidal (encrypted); the OAuth callback now uses them + evicts the cached client. - Wired the Tidal playlist reads (list + tracks) to the per-profile client; the module import line left intact. - My Accounts: Tidal row (Connect via /auth/tidal?profile_id=, status, Disconnect). Connections API extended; disconnect made generic (//disconnect). Admin sees "managed in Settings" for every service. Tests: per-profile token refresh writes to the profile and leaves the global tidal_tokens untouched (the safety guarantee); connect status + disconnect; admin/unconnected → global client. 22 endpoint tests pass. --- database/music_database.py | 42 ++++++++ tests/test_credentials_endpoints.py | 50 +++++++++ web_server.py | 154 +++++++++++++++++++++------- webui/static/my-accounts.js | 13 ++- webui/static/style.css | 1 + 5 files changed, 220 insertions(+), 40 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index e12700a0..7b352a34 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -4747,6 +4747,48 @@ class MusicDatabase: logger.error(f"Error setting Spotify tokens for profile {profile_id}: {e}") return False + def set_profile_tidal_tokens(self, profile_id: int, access_token: str, refresh_token: str) -> bool: + """Save Tidal OAuth tokens for a profile (encrypted). Used by the + per-profile Tidal client's token refresh — keeps a profile's refresh from + ever touching the global tidal_tokens slot.""" + try: + from config.settings import config_manager + enc_access = config_manager._encrypt_value(access_token) if access_token else None + enc_refresh = config_manager._encrypt_value(refresh_token) if refresh_token else None + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE profiles + SET tidal_access_token = ?, tidal_refresh_token = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (enc_access, enc_refresh, profile_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error setting Tidal tokens for profile {profile_id}: {e}") + return False + + def get_profile_tidal(self, profile_id: int) -> Dict[str, Any]: + """Get decrypted Tidal tokens for a profile ({} if none).""" + try: + from config.settings import config_manager + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT tidal_access_token, tidal_refresh_token FROM profiles WHERE id = ?", + (profile_id,)) + row = cursor.fetchone() + if not row or not row[0]: + return {} + return { + 'access_token': config_manager._decrypt_value(row[0]) if row[0] else '', + 'refresh_token': config_manager._decrypt_value(row[1]) if row[1] else '', + } + except Exception as e: + logger.error(f"Error getting Tidal tokens for profile {profile_id}: {e}") + return {} + def set_profile_server_library(self, profile_id: int, server_type: str, library_id: str = None, user_id: str = None) -> bool: """Save media server library/user selection for a profile.""" diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index 8c448e8d..286579a3 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -216,3 +216,53 @@ def test_admin_connections_marks_admin(client): 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 + + +# ── Tidal: per-profile connect status + the token-save-redirect safety ──────── + +def test_tidal_connection_status_and_disconnect(client, nonadmin_profile): + db = web_server.get_database() + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + # unconnected + assert client.get('/api/profiles/me/connections').get_json()['connections']['tidal']['connected'] is False + # seed tokens → connected + db.set_profile_tidal_tokens(nonadmin_profile, 'acc-tok', 'ref-tok') + assert client.get('/api/profiles/me/connections').get_json()['connections']['tidal']['connected'] is True + # disconnect → cleared + assert client.post('/api/profiles/me/connections/tidal/disconnect').get_json()['success'] + assert db.get_profile_tidal(nonadmin_profile) == {} + assert client.get('/api/profiles/me/connections').get_json()['connections']['tidal']['connected'] is False + + +def test_disconnect_unsupported_service_400(client, nonadmin_profile): + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + assert client.post('/api/profiles/me/connections/deezer/disconnect').status_code == 400 + + +def test_tidal_token_refresh_redirects_to_profile_not_global(client, nonadmin_profile): + # THE safety guarantee: a per-profile Tidal client's token save must write to + # the PROFILE, never the global tidal_tokens slot the app runs on. + from config.settings import config_manager + db = web_server.get_database() + config_manager.set('tidal_tokens', {'access_token': 'ADMIN-ACC', 'refresh_token': 'ADMIN-REF'}) + db.set_profile_tidal_tokens(nonadmin_profile, 'p-acc', 'p-ref') + web_server.clear_profile_tidal_client(nonadmin_profile) + + c = web_server.get_tidal_client_for_profile(nonadmin_profile) + assert c is not web_server.tidal_client # a dedicated per-profile client + # simulate a refresh writing new tokens + c.access_token = 'p-acc-NEW' + c.refresh_token = 'p-ref-NEW' + c._save_tokens() + + assert db.get_profile_tidal(nonadmin_profile) == {'access_token': 'p-acc-NEW', 'refresh_token': 'p-ref-NEW'} + # global slot untouched + assert config_manager.get('tidal_tokens') == {'access_token': 'ADMIN-ACC', 'refresh_token': 'ADMIN-REF'} + + +def test_tidal_admin_and_unconnected_use_global_client(client): + assert web_server.get_tidal_client_for_profile(1) is web_server.tidal_client + assert web_server.get_tidal_client_for_profile(None) is web_server.tidal_client + assert web_server.get_tidal_client_for_profile(987654) is web_server.tidal_client diff --git a/web_server.py b/web_server.py index 1d347422..9f5e1cd0 100644 --- a/web_server.py +++ b/web_server.py @@ -572,6 +572,60 @@ def get_spotify_client_for_profile(profile_id=None): profile_id = get_current_profile_id() return metadata_registry.get_spotify_client_for_profile(profile_id) + +_profile_tidal_clients = {} +_profile_tidal_lock = threading.Lock() + + +def get_tidal_client_for_profile(profile_id=None): + """Get the Tidal client for a profile's OWN playlists, or the global one. + + A profile that has connected its own Tidal account gets a dedicated client + seeded with its tokens (refreshed via the shared/global app creds). Crucially + its token refresh is redirected to the profile row, so a per-profile refresh + never overwrites the global tidal_tokens the app runs on. Admin (profile 1) + and unconnected profiles use the global client unchanged.""" + if profile_id is None: + profile_id = get_current_profile_id() + if not profile_id or profile_id == 1: + return tidal_client + try: + toks = get_database().get_profile_tidal(profile_id) or {} + except Exception: + return tidal_client + if not toks.get('access_token') and not toks.get('refresh_token'): + return tidal_client + with _profile_tidal_lock: + cached = _profile_tidal_clients.get(profile_id) + if cached is not None: + return cached + try: + c = TidalClient() + c.access_token = toks.get('access_token') or None + c.refresh_token = toks.get('refresh_token') or None + c.token_expires_at = 0 # force a refresh check on first use + if c.access_token: + c.session.headers['Authorization'] = f'Bearer {c.access_token}' + # Redirect token persistence to the PROFILE, never the global slot. + _pid = profile_id + def _save_to_profile(_c=c, _p=_pid): + try: + get_database().set_profile_tidal_tokens(_p, _c.access_token, _c.refresh_token) + except Exception as e: + logger.debug("per-profile Tidal token save failed: %s", e) + c._save_tokens = _save_to_profile + _profile_tidal_clients[profile_id] = c + return c + except Exception as e: + logger.error("per-profile Tidal client build failed for %s: %s", profile_id, e) + return tidal_client + + +def clear_profile_tidal_client(profile_id): + """Evict a profile's cached Tidal client (after (dis)connect).""" + with _profile_tidal_lock: + _profile_tidal_clients.pop(profile_id, None) + # Valid page IDs for profile permission validation VALID_PAGE_IDS = { 'dashboard', @@ -4985,20 +5039,9 @@ def tidal_callback(): if profile_id_for_tidal: try: profile_id_int = int(profile_id_for_tidal) - db = get_database() - # Store Tidal tokens on the profile - from config.settings import config_manager as _cm - enc_access = _cm._encrypt_value(temp_tidal_client.access_token) if temp_tidal_client.access_token else None - enc_refresh = _cm._encrypt_value(temp_tidal_client.refresh_token) if temp_tidal_client.refresh_token else None - with db._get_connection() as conn: - cursor = conn.cursor() - cursor.execute(""" - UPDATE profiles - SET tidal_access_token = ?, tidal_refresh_token = ?, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, (enc_access, enc_refresh, profile_id_int)) - conn.commit() + get_database().set_profile_tidal_tokens( + profile_id_int, temp_tidal_client.access_token, temp_tidal_client.refresh_token) + clear_profile_tidal_client(profile_id_int) # rebuild with fresh tokens add_activity_item("", "Tidal Auth Complete", f"Profile {profile_id_int} authenticated with Tidal", "Now") return "

Tidal Authentication Successful!

Your personal Tidal account is now connected. You can close this window.

" except Exception as profile_err: @@ -21163,11 +21206,12 @@ def tidal_disconnect(): @app.route('/api/tidal/playlists', methods=['GET']) def get_tidal_playlists(): """Fetches all user playlists from Tidal with full track data (like sync.py).""" - if not tidal_client or not tidal_client.is_authenticated(): + client = get_tidal_client_for_profile() or tidal_client + if not client or not client.is_authenticated(): return jsonify({"error": "Tidal not authenticated."}), 401 try: # Use same method as sync.py - this already includes all track data - playlists = tidal_client.get_user_playlists_metadata_only() + playlists = client.get_user_playlists_metadata_only() playlist_data = [] for p in playlists: @@ -21212,8 +21256,8 @@ def get_tidal_playlists(): COLLECTION_PLAYLIST_NAME, COLLECTION_PLAYLIST_DESCRIPTION, ) - collection_count = tidal_client.get_collection_tracks_count() - needs_reconnect = tidal_client.collection_needs_reconnect() + collection_count = client.get_collection_tracks_count() + needs_reconnect = client.collection_needs_reconnect() if needs_reconnect: playlist_data.append({ @@ -21254,7 +21298,8 @@ def get_tidal_playlists(): @app.route('/api/tidal/playlist/', methods=['GET']) def get_tidal_playlist_tracks(playlist_id): """Fetches full track details for a specific Tidal playlist (matches sync.py pattern).""" - if not tidal_client or not tidal_client.is_authenticated(): + client = get_tidal_client_for_profile() or tidal_client + if not client or not client.is_authenticated(): return jsonify({"error": "Tidal not authenticated."}), 401 try: logger.info(f"Getting full Tidal playlist with tracks for: {playlist_id}") @@ -21263,7 +21308,7 @@ def get_tidal_playlist_tracks(playlist_id): # `get_playlist` recognizes the virtual `tidal-favorites` ID and # dispatches to the userCollectionTracks endpoint internally, so # the rest of this handler treats it identically to a real playlist. - full_playlist = tidal_client.get_playlist(playlist_id) + full_playlist = client.get_playlist(playlist_id) if not full_playlist: return jsonify({"error": "Playlist not found or unable to access. This may be due to privacy settings or Tidal API restrictions."}), 404 @@ -25371,6 +25416,20 @@ def _profile_spotify_connection(profile_id): return (False, None) +def _profile_tidal_connection(profile_id): + """(connected, account_name) for a profile's OWN Tidal. Connected = it has + stored tokens; validity is checked (and refreshed) on actual use, so this + stays a cheap no-network check for the modal.""" + if not profile_id or profile_id == 1: + return (False, None) + try: + toks = get_database().get_profile_tidal(profile_id) or {} + return (bool(toks.get('refresh_token') or toks.get('access_token')), None) + except Exception as e: + logger.debug("profile %s tidal 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. @@ -25378,36 +25437,59 @@ def get_my_connections(): try: pid = get_current_profile_id() sp_connected, sp_account = _profile_spotify_connection(pid) + td_connected, td_account = _profile_tidal_connection(pid) return jsonify({ 'success': True, 'is_admin': pid == 1, 'connections': { 'spotify': {'connected': sp_connected, 'account': sp_account}, + 'tidal': {'connected': td_connected, 'account': td_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.""" +def _disconnect_profile_spotify(pid): + 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) + + +def _disconnect_profile_tidal(pid): + try: + get_database().set_profile_tidal_tokens(pid, '', '') + except Exception as e: + logger.debug("could not clear profile tidal tokens: %s", e) + clear_profile_tidal_client(pid) + + +_PROFILE_DISCONNECTORS = { + 'spotify': _disconnect_profile_spotify, + 'tidal': _disconnect_profile_tidal, +} + + +@app.route('/api/profiles/me/connections//disconnect', methods=['POST']) +def disconnect_my_connection(service): + """Disconnect the current profile's OWN account for a service (clears its + per-profile tokens + cached client). The global/admin auth is untouched.""" try: pid = get_current_profile_id() + fn = _PROFILE_DISCONNECTORS.get(service) + if not fn: + return jsonify({'success': False, 'error': f'Unsupported service: {service}'}), 400 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': False, 'error': 'The admin account is managed in Settings'}), 400 + fn(pid) return jsonify({'success': True}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/webui/static/my-accounts.js b/webui/static/my-accounts.js index 6201af03..71325090 100644 --- a/webui/static/my-accounts.js +++ b/webui/static/my-accounts.js @@ -18,6 +18,11 @@ const _MA_SERVICES = [ logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', connect: (pid) => `/auth/spotify?profile_id=${pid}`, }, + { + id: 'tidal', name: 'Tidal', brand: '#00cfe8', dark: true, + logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tidal-light.png', + connect: (pid) => `/auth/tidal?profile_id=${pid}`, + }, ]; function _maEsc(s) { @@ -84,9 +89,9 @@ function _maRender(body, data) { 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'); + // Admin uses the global app account (set up in Settings) for every + // service — not a personal connection here. + const adminNote = isAdmin; let action; if (adminNote) { action = `Managed in Settings (app account)`; @@ -99,7 +104,7 @@ function _maRender(body, data) { } return `
-
${_maEsc(svc.name)}
diff --git a/webui/static/style.css b/webui/static/style.css index 54829bd4..a8710691 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -67722,3 +67722,4 @@ body.em-scroll-lock { overflow: hidden; } .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; } +.ma-disc.ma-disc--dark { background: #1f2329; } From af1a35385c33c19dba8338e4abc5ed10f804864c Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 13:32:29 -0700 Subject: [PATCH 20/52] Profiles: ListenBrainz in My Accounts; Personal Settings now just server library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third service (the easy one — ListenBrainz already had a working per-profile token path). Consolidated all per-profile streaming accounts into the My Accounts modal: - My Accounts gains a ListenBrainz row with a token-paste connect (a new 'token' service type alongside the OAuth-popup ones), reusing the existing /api/profiles/me/listenbrainz save + the generic disconnect. - Connections API reports listenbrainz status (connected + username). - Personal Settings (the gear modal) dropped its Spotify/Tidal/ListenBrainz sections — those duplicated My Accounts — and now shows only the per-profile server-library selection (non-admin) or a pointer note (admin). The old renderPersonalSettings{Spotify,Tidal,LB} functions are left defined but unused. So every per-profile account connection (Spotify, Tidal, ListenBrainz) now lives in one place. Tests: LB connect status + disconnect via the generic endpoint. 23 endpoint tests pass; 64 integrity tests pass. --- tests/test_credentials_endpoints.py | 20 ++++++++++ web_server.py | 23 +++++++++++ webui/static/init.js | 62 ++++------------------------- webui/static/my-accounts.js | 35 ++++++++++++++++ webui/static/style.css | 5 +++ 5 files changed, 90 insertions(+), 55 deletions(-) diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index 286579a3..575e3378 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -266,3 +266,23 @@ def test_tidal_admin_and_unconnected_use_global_client(client): assert web_server.get_tidal_client_for_profile(1) is web_server.tidal_client assert web_server.get_tidal_client_for_profile(None) is web_server.tidal_client assert web_server.get_tidal_client_for_profile(987654) is web_server.tidal_client + + +# ── ListenBrainz: per-profile connect status + disconnect (token-paste) ─────── + +def test_listenbrainz_connection_status_and_disconnect(client, nonadmin_profile): + db = web_server.get_database() + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + # unconnected + conns = client.get('/api/profiles/me/connections').get_json()['connections'] + assert 'listenbrainz' in conns and conns['listenbrainz']['connected'] is False + # seed a token directly (POST validates against the live API; this tests the + # status + disconnect wiring without a network call) + db.set_profile_listenbrainz(nonadmin_profile, 'lb-token', '', 'lbuser') + conns = client.get('/api/profiles/me/connections').get_json()['connections'] + assert conns['listenbrainz']['connected'] is True + assert conns['listenbrainz']['account'] == 'lbuser' + # disconnect via the generic endpoint + assert client.post('/api/profiles/me/connections/listenbrainz/disconnect').get_json()['success'] + assert client.get('/api/profiles/me/connections').get_json()['connections']['listenbrainz']['connected'] is False diff --git a/web_server.py b/web_server.py index 9f5e1cd0..e1b1dfa6 100644 --- a/web_server.py +++ b/web_server.py @@ -25430,6 +25430,19 @@ def _profile_tidal_connection(profile_id): return (False, None) +def _profile_listenbrainz_connection(profile_id): + """(connected, username) for a profile's OWN ListenBrainz token.""" + if not profile_id or profile_id == 1: + return (False, None) + try: + s = get_database().get_profile_listenbrainz(profile_id) or {} + if s.get('token'): + return (True, s.get('username')) + except Exception as e: + logger.debug("profile %s listenbrainz 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. @@ -25438,12 +25451,14 @@ def get_my_connections(): pid = get_current_profile_id() sp_connected, sp_account = _profile_spotify_connection(pid) td_connected, td_account = _profile_tidal_connection(pid) + lb_connected, lb_account = _profile_listenbrainz_connection(pid) return jsonify({ 'success': True, 'is_admin': pid == 1, 'connections': { 'spotify': {'connected': sp_connected, 'account': sp_account}, 'tidal': {'connected': td_connected, 'account': td_account}, + 'listenbrainz': {'connected': lb_connected, 'account': lb_account}, }, }) except Exception as e: @@ -25472,9 +25487,17 @@ def _disconnect_profile_tidal(pid): clear_profile_tidal_client(pid) +def _disconnect_profile_listenbrainz(pid): + try: + get_database().clear_profile_listenbrainz(pid) + except Exception as e: + logger.debug("could not clear profile listenbrainz: %s", e) + + _PROFILE_DISCONNECTORS = { 'spotify': _disconnect_profile_spotify, 'tidal': _disconnect_profile_tidal, + 'listenbrainz': _disconnect_profile_listenbrainz, } diff --git a/webui/static/init.js b/webui/static/init.js index 6d069d68..f81f2968 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -914,56 +914,16 @@ async function openPersonalSettings() { body.innerHTML = '
Loading...
'; try { - // Load all per-profile service data in parallel - const [lbRes, spotifyRes] = await Promise.all([ - fetch('/api/profiles/me/listenbrainz'), - fetch('/api/profiles/me/spotify'), - ]); - const lbData = await lbRes.json(); - const spotifyData = await spotifyRes.json(); - body.innerHTML = ''; const isNonAdmin = currentProfile && !currentProfile.is_admin; + // Streaming-account connections now live in the My Accounts modal (the ♫ + // button). Personal Settings keeps only the per-profile server library. if (isNonAdmin) { - // Tabbed layout for non-admin with multiple sections - const tabs = [ - { id: 'music', label: 'Music Services' }, - { id: 'server', label: 'Server' }, - { id: 'scrobble', label: 'Scrobbling' }, - ]; - const tabBar = document.createElement('div'); - tabBar.className = 'ps-tabbar'; - tabs.forEach((t, i) => { - const btn = document.createElement('button'); - btn.className = 'ps-tab' + (i === 0 ? ' active' : ''); - btn.textContent = t.label; - btn.onclick = () => { - tabBar.querySelectorAll('.ps-tab').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - body.querySelectorAll('.ps-tab-content').forEach(c => c.classList.remove('active')); - const target = document.getElementById(`ps-tab-${t.id}`); - if (target) target.classList.add('active'); - }; - tabBar.appendChild(btn); - }); - body.appendChild(tabBar); - - // Music Services tab - const musicTab = document.createElement('div'); - musicTab.id = 'ps-tab-music'; - musicTab.className = 'ps-tab-content active'; - renderPersonalSettingsSpotify(musicTab, spotifyData); - renderPersonalSettingsTidal(musicTab); - body.appendChild(musicTab); - - // Server tab const serverTab = document.createElement('div'); - serverTab.id = 'ps-tab-server'; - serverTab.className = 'ps-tab-content'; + serverTab.style.padding = '18px 22px 22px'; serverTab.innerHTML = '
Loading libraries...
'; body.appendChild(serverTab); - // Load server libraries async (don't block modal) fetch('/api/profiles/me/server-library').then(r => r.json()).then(libData => { serverTab.innerHTML = ''; renderPersonalSettingsServerLibrary(serverTab, libData); @@ -971,21 +931,13 @@ async function openPersonalSettings() { serverTab.innerHTML = ''; renderPersonalSettingsServerLibrary(serverTab, {}); }); - - // Scrobbling tab - const scrobbleTab = document.createElement('div'); - scrobbleTab.id = 'ps-tab-scrobble'; - scrobbleTab.className = 'ps-tab-content'; - body.appendChild(scrobbleTab); - // Render LB into the scrobble tab - const origBody = body; - renderPersonalSettingsLB(lbData, scrobbleTab); } else { - // Admin: just ListenBrainz, no tabs const content = document.createElement('div'); - content.style.padding = '18px 22px 22px'; + content.style.padding = '24px'; + content.innerHTML = '
' + + 'Your streaming accounts are in My Accounts (the ♫ button next to your profile).
' + + 'Global service setup lives in Settings.
'; body.appendChild(content); - renderPersonalSettingsLB(lbData, content); } } catch (e) { body.innerHTML = '
Failed to load settings
'; diff --git a/webui/static/my-accounts.js b/webui/static/my-accounts.js index 71325090..e77e1a4e 100644 --- a/webui/static/my-accounts.js +++ b/webui/static/my-accounts.js @@ -23,6 +23,13 @@ const _MA_SERVICES = [ logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tidal-light.png', connect: (pid) => `/auth/tidal?profile_id=${pid}`, }, + { + id: 'listenbrainz', name: 'ListenBrainz', brand: '#eb743b', dark: true, + logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/listenbrainz.png', + type: 'token', + saveUrl: '/api/profiles/me/listenbrainz', + hint: 'Paste your token from listenbrainz.org/profile', + }, ]; function _maEsc(s) { @@ -99,6 +106,11 @@ function _maRender(body, data) { action = ` `; + } else if (svc.type === 'token') { + action = ` + + `; } else { action = ``; } @@ -135,6 +147,29 @@ function connectMyAccount(serviceId) { }, 800); } +async function saveMyAccountToken(serviceId) { + const svc = _MA_SERVICES.find(s => s.id === serviceId); + if (!svc || !svc.saveUrl) return; + const input = document.getElementById(`ma-token-${serviceId}`); + const token = (input && input.value || '').trim(); + if (!token) { if (typeof showToast === 'function') showToast('Paste a token first', 'info'); return; } + try { + const res = await fetch(svc.saveUrl, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + const data = await res.json(); + if (data.success) { + if (typeof showToast === 'function') showToast(`${svc.name} connected`, 'success'); + _maLoad(); + } else if (typeof showToast === 'function') { + showToast(data.error || 'Could not connect', 'error'); + } + } catch (e) { + if (typeof showToast === 'function') showToast('Could not connect', 'error'); + } +} + async function disconnectMyAccount(serviceId) { if (!confirm(`Disconnect your ${serviceId} account from this profile?`)) return; try { diff --git a/webui/static/style.css b/webui/static/style.css index a8710691..42584cb2 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -67723,3 +67723,8 @@ body.em-scroll-lock { overflow: hidden; } .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; } .ma-disc.ma-disc--dark { background: #1f2329; } +.ma-token-input { + background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; + padding: 7px 11px; color: #fff; font-size: 0.82rem; width: 150px; +} +.ma-token-input:focus { outline: none; border-color: var(--ma-brand); } From 186cbf0fcc6550c2dadc27f24b8be2e962c83764 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 13:49:25 -0700 Subject: [PATCH 21/52] Profiles: Tidal logo on a light disc (dark logo) in My Accounts --- webui/static/my-accounts.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webui/static/my-accounts.js b/webui/static/my-accounts.js index e77e1a4e..2ae792d0 100644 --- a/webui/static/my-accounts.js +++ b/webui/static/my-accounts.js @@ -19,8 +19,8 @@ const _MA_SERVICES = [ connect: (pid) => `/auth/spotify?profile_id=${pid}`, }, { - id: 'tidal', name: 'Tidal', brand: '#00cfe8', dark: true, - logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tidal-light.png', + id: 'tidal', name: 'Tidal', brand: '#00cfe8', + logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tidal.png', connect: (pid) => `/auth/tidal?profile_id=${pid}`, }, { From e5b30d6e635af2a707c7af19ad5d0c13021a9c2c Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 14:10:58 -0700 Subject: [PATCH 22/52] Profiles: restore hand cursor on the clickable Service Status section The cursor:pointer + hover rules were collateral damage when the dead credential-set CSS block was removed. Re-added them (admin = pointer on the section + its rows; non-admin stays default). --- webui/static/style.css | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/webui/static/style.css b/webui/static/style.css index 42584cb2..0476eb6a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -67674,8 +67674,12 @@ body.em-scroll-lock { overflow: hidden; } .ss-hybrid-logo { width: 26px; height: 26px; object-fit: contain; } .ss-hybrid-name { font-size: 0.9rem; } -/* Service Status is admin-only — non-admins get no clickable affordance. */ -.status-section--locked { cursor: default; } +/* Service Status quick-switch (admin only) — show the hand cursor so it's + clearly clickable; non-admins get no clickable affordance. */ +.status-section--clickable { cursor: pointer; border-radius: 10px; transition: background 0.15s; } +.status-section--clickable:hover { background: rgba(255, 255, 255, 0.04); } +.status-section--clickable .status-indicator { cursor: pointer; } +.status-section--locked, .status-section--locked .status-indicator { cursor: default; } .status-section--locked:hover { background: transparent; } /* ── My Accounts (per-profile self-auth for playlist services) ── */ From 35ffc9f5e29e72840768edf45ec99ff94c883898 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 14:27:35 -0700 Subject: [PATCH 23/52] tests: use soulsync-testdb- prefix in the web_server endpoint tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My credential/admin-gating endpoint tests override DATABASE_PATH at import to a temp dir, but with a custom prefix — which tripped the DB-isolation guard (test_database_path_env_is_isolated requires 'soulsync-testdb-' in the path) once those modules loaded. Still a temp DB (no real-DB risk), just the wrong prefix. Switched to the soulsync-testdb- prefix so isolation is preserved and the guard passes. --- tests/test_admin_gating.py | 2 +- tests/test_credentials_endpoints.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_admin_gating.py b/tests/test_admin_gating.py index 06dc1adf..6ca61f56 100644 --- a/tests/test_admin_gating.py +++ b/tests/test_admin_gating.py @@ -16,7 +16,7 @@ import tempfile import pytest -_TMP = tempfile.mkdtemp(prefix='ss-admin-gate-') +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-gate-') os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'gate.db') os.environ['SOULSYNC_TEST_DB_READY'] = '1' diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index 575e3378..287fee5b 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -17,7 +17,7 @@ import tempfile import pytest # Redirect the DB before importing web_server so it never touches a real library. -_TMP = tempfile.mkdtemp(prefix='ss-cred-ep-') +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-cred-') os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'creds_ep.db') os.environ['SOULSYNC_TEST_DB_READY'] = '1' From 6980253a9603e412e9f123d2aa204fc06a1e0cd5 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 15:37:56 -0700 Subject: [PATCH 24/52] Automations: run each as its OWNER profile in the background (part 1 of per-profile sync) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background automations had no session, so get_current_profile_id() fell back to admin (1) — wrong for a non-admin's scheduled job. Now the engine declares the automation's owner around handler execution via a contextvar (core/profile_context.py), and get_current_profile_id() consults it only when there's NO web request. So: - a real logged-in request always wins (foreground unchanged), - admin + system automations are profile 1 → resolve to admin exactly as before (the 8 admin-owned auto-sync pipelines behave identically), - only non-admin-owned automations gain their correct identity, deep through the whole call chain (incl. the per-profile client resolvers) — no threading profile_id through dozens of signatures. Reset in a finally so a pooled thread can't leak the override to the next job. Tests: contextvar set/reset/nested; get_current_profile_id honours the override only outside a request (a real session still wins); and end-to-end — the engine runs a non-admin automation as profile 4, an admin one as 1, an explicit trigger profile overrides the owner, and the context resets even when the handler raises. 27 + 4 tests pass. Part 2 (next): point the sync handlers' source-playlist READ at get_spotify_client_for_profile so a non-admin's auto-sync pulls THEIR playlist. --- core/automation_engine.py | 14 +++- core/profile_context.py | 45 +++++++++++++ .../automation/test_engine_profile_context.py | 64 +++++++++++++++++++ tests/test_credentials_endpoints.py | 29 +++++++++ tests/test_profile_context.py | 31 +++++++++ web_server.py | 12 +++- 6 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 core/profile_context.py create mode 100644 tests/automation/test_engine_profile_context.py create mode 100644 tests/test_profile_context.py diff --git a/core/automation_engine.py b/core/automation_engine.py index 5264c9c5..1b85e221 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -635,6 +635,11 @@ class AutomationEngine: action_config['_automation_name'] = auto.get('name', '') if profile_id is not None: action_config['_profile_id'] = profile_id + # The profile this run acts AS: an explicit trigger profile, else the + # automation's owner, else admin. System + admin automations are + # profile 1, so this is a no-op for them — only non-admin-owned + # automations gain their correct identity in the background. + _effective_profile_id = profile_id if profile_id is not None else (auto.get('profile_id') or 1) # Action delay (skipped for manual run_now) delay_minutes = action_config.get('delay', 0) @@ -681,9 +686,14 @@ class AutomationEngine: except Exception as e: logger.debug("scheduled progress init: %s", e) - # Execute the action + # Execute the action under the owner's profile so get_current_profile_id() + # (and the per-profile clients it resolves) act as the automation's owner + # in the background, not admin. Reset in finally so a pooled thread can't + # leak the override to the next job. error = None result = {} + from core.profile_context import set_background_profile, reset_background_profile + _bg_token = set_background_profile(_effective_profile_id) try: result = handler_info['handler'](action_config) or {} logger.info(f"Automation '{auto['name']}' (id={automation_id}) executed: {result.get('status', 'ok')}") @@ -702,6 +712,8 @@ class AutomationEngine: error = str(e) result = {'status': 'error', 'error': error} logger.error(f"Automation '{auto['name']}' (id={automation_id}) failed: {e}") + finally: + reset_background_profile(_bg_token) # Finalize progress tracking if self._progress_finish_fn: diff --git a/core/profile_context.py b/core/profile_context.py new file mode 100644 index 00000000..5f4d6879 --- /dev/null +++ b/core/profile_context.py @@ -0,0 +1,45 @@ +"""Background profile context. + +Work that runs OUTSIDE a web request — the automation engine, scheduled jobs — +has no Flask session, so ``get_current_profile_id()`` falls back to admin +(profile 1). That's wrong for an automation owned by a non-admin: their +playlist pull, their per-profile writes, should act as THEM. + +This lets the engine declare "the work below is running for profile X" around a +unit of background work (set/reset in a try/finally). ``get_current_profile_id`` +consults it only when there's no real request — so an actual logged-in session +always wins, and nothing changes for foreground/admin paths. Built on a +``ContextVar`` so the value is scoped to the running call and reset cleanly, +even on thread-pool reuse. +""" + +from __future__ import annotations + +import contextvars + +_background_profile_id: "contextvars.ContextVar[int | None]" = contextvars.ContextVar( + "background_profile_id", default=None +) + + +def set_background_profile(profile_id): + """Declare the profile for the current background unit of work. Returns a + token to pass to reset_background_profile (use in try/finally).""" + return _background_profile_id.set(profile_id) + + +def reset_background_profile(token) -> None: + """Restore the previous background profile (clears the override).""" + try: + _background_profile_id.reset(token) + except Exception: + # Token from a different context — clear to the default rather than leak. + _background_profile_id.set(None) + + +def get_background_profile(): + """The background profile in effect, or None if none is set.""" + return _background_profile_id.get() + + +__all__ = ["set_background_profile", "reset_background_profile", "get_background_profile"] diff --git a/tests/automation/test_engine_profile_context.py b/tests/automation/test_engine_profile_context.py new file mode 100644 index 00000000..ba03731d --- /dev/null +++ b/tests/automation/test_engine_profile_context.py @@ -0,0 +1,64 @@ +"""The engine runs each automation AS its owner profile (per-profile automations). + +A non-admin's scheduled job must execute with their profile in the background +context, so get_current_profile_id() / the per-profile clients act as them — not +admin. Admin/system automations (profile 1) are unchanged. The context must be +reset after every run so a pooled thread can't leak it. +""" + +from unittest.mock import MagicMock + +import pytest + +from core.automation_engine import AutomationEngine +from core.profile_context import get_background_profile + + +def _engine(owner_profile_id): + db = MagicMock() + db.get_automation.return_value = { + 'id': 1, 'name': 'Auto-Sync', 'enabled': True, + 'action_type': 'sync_playlist', 'action_config': '{}', + 'trigger_type': 'interval_hours', 'trigger_config': '{"hours": 1}', + 'profile_id': owner_profile_id, + } + db.update_automation_run = MagicMock(return_value=True) + eng = AutomationEngine(db) + eng._running = True + seen = {} + eng._action_handlers['sync_playlist'] = { + 'handler': lambda config: seen.update(profile=get_background_profile()) or {'status': 'completed'}, + 'guard': None, + } + return eng, seen + + +def test_nonadmin_owned_automation_runs_as_owner(): + eng, seen = _engine(owner_profile_id=4) + eng.run_automation(1, skip_delay=True) + assert seen['profile'] == 4 # handler ran AS profile 4 + assert get_background_profile() is None # reset after the run + + +def test_admin_owned_automation_runs_as_admin(): + eng, seen = _engine(owner_profile_id=1) + eng.run_automation(1, skip_delay=True) + assert seen['profile'] == 1 # unchanged for admin/system + assert get_background_profile() is None + + +def test_explicit_trigger_profile_overrides_owner(): + # A manual trigger (run_automation(profile_id=...)) wins over the owner. + eng, seen = _engine(owner_profile_id=4) + eng.run_automation(1, skip_delay=True, profile_id=9) + assert seen['profile'] == 9 + + +def test_context_reset_even_when_handler_raises(): + eng, _ = _engine(owner_profile_id=4) + eng._action_handlers['sync_playlist'] = { + 'handler': lambda config: (_ for _ in ()).throw(RuntimeError('boom')), + 'guard': None, + } + eng.run_automation(1, skip_delay=True) # error is caught + stored + assert get_background_profile() is None # finally reset it diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index 287fee5b..9faa037a 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -286,3 +286,32 @@ def test_listenbrainz_connection_status_and_disconnect(client, nonadmin_profile) # disconnect via the generic endpoint assert client.post('/api/profiles/me/connections/listenbrainz/disconnect').get_json()['success'] assert client.get('/api/profiles/me/connections').get_json()['connections']['listenbrainz']['connected'] is False + + +# ── Background profile context drives get_current_profile_id() (part 1) ──────── + +def test_background_profile_override_when_no_request(): + # Outside a web request, get_current_profile_id() honours the engine's + # background override; admin (default) and cleared state stay profile 1. + from core.profile_context import set_background_profile, reset_background_profile + assert web_server.get_current_profile_id() == 1 # no override → admin + tok = set_background_profile(7) + try: + assert web_server.get_current_profile_id() == 7 # acts as the owner + finally: + reset_background_profile(tok) + assert web_server.get_current_profile_id() == 1 # reset → admin + + +def test_real_session_still_wins_over_background(client, nonadmin_profile): + # A genuine request's session profile must override any background context. + from core.profile_context import set_background_profile, reset_background_profile + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + tok = set_background_profile(999) # a bogus background override + try: + # the request resolves to the SESSION profile, not the background one + body = client.get('/api/profiles/me/connections').get_json() + assert body['is_admin'] is False # it's the non-admin session, not 999/admin + finally: + reset_background_profile(tok) diff --git a/tests/test_profile_context.py b/tests/test_profile_context.py new file mode 100644 index 00000000..29ef17d4 --- /dev/null +++ b/tests/test_profile_context.py @@ -0,0 +1,31 @@ +"""Background profile context (per-profile automations). + +Lets background work (the automation engine) declare which profile it's acting +for, so get_current_profile_id() resolves to the automation's OWNER instead of +admin when there's no web request. A real request must still win; admin/system +(profile 1) and no-override must stay admin. +""" + +from __future__ import annotations + +from core.profile_context import ( + set_background_profile, reset_background_profile, get_background_profile, +) + + +def test_set_reset_roundtrip(): + assert get_background_profile() is None + tok = set_background_profile(5) + assert get_background_profile() == 5 + reset_background_profile(tok) + assert get_background_profile() is None + + +def test_nested_set_reset(): + t1 = set_background_profile(3) + t2 = set_background_profile(1) # e.g. an admin/system sub-run + assert get_background_profile() == 1 + reset_background_profile(t2) + assert get_background_profile() == 3 # back to the outer profile + reset_background_profile(t1) + assert get_background_profile() is None # fully cleared (no leak) diff --git a/web_server.py b/web_server.py index e1b1dfa6..344d77da 100644 --- a/web_server.py +++ b/web_server.py @@ -529,11 +529,19 @@ def get_current_profile_id() -> int: scanner) have no request context, so `g.profile_id` raises `RuntimeError("Working outside of application context")` rather than `AttributeError`. Catch both so non-request callers degrade - to the admin profile instead of crashing the handler.""" + to the admin profile instead of crashing the handler. + + A real web request always wins. Only when there's NO request do we honour a + background-profile override (set by the automation engine to the automation's + owner) — so a non-admin's scheduled job acts as them, while admin/system jobs + (profile 1) and anything with no override resolve to admin exactly as before.""" try: return g.profile_id except (AttributeError, RuntimeError): - return 1 + pass + from core.profile_context import get_background_profile + pid = get_background_profile() + return pid if pid is not None else 1 def admin_only(view_fn): From 783839349c0dd24da80cf8b4da22763e23de51eb Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 15:45:45 -0700 Subject: [PATCH 25/52] Automations: per-profile playlist source reads in auto-sync (part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playlist source registry built the Spotify/Tidal adapters with a client GETTER (resolved fresh on every read), but web_server passed `lambda: `. Swapped those to get_spotify_client_for_profile / get_tidal_client_for_profile. Combined with part 1 (the engine running each automation as its owner), an auto-sync pipeline now reads its source playlist through the OWNER's account: - interactive sync → the user's session profile, - background automation → the automation owner (via core.profile_context), - admin / profile 1 → the global client, so the admin's existing auto-sync pipelines pull exactly as before. The adapters re-resolve per read, so a singleton registry is fine. Deezer/Qobuz getters left global (their playlist login is tangled with downloads — deferred). Tests: the Spotify/Tidal source adapters resolve the global client under admin and re-resolve through the profile context per call (unconnected → safe global fallback). 27 endpoint/profile tests pass. --- tests/test_credentials_endpoints.py | 26 ++++++++++++++++++++++++++ web_server.py | 10 ++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index 9faa037a..592b3973 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -315,3 +315,29 @@ def test_real_session_still_wins_over_background(client, nonadmin_profile): assert body['is_admin'] is False # it's the non-admin session, not 999/admin finally: reset_background_profile(tok) + + +# ── Part 2: the playlist SOURCE adapters read per-profile (sync handlers) ────── +# bootstrap now passes get_*_client_for_profile as the source adapters' +# client_getter; these prove that composition resolves per the current profile +# context and stays on the global client for admin (the existing pipelines). + +def test_spotify_source_adapter_resolves_per_profile(): + from core.playlists.sources.spotify import SpotifyPlaylistSource + src = SpotifyPlaylistSource(web_server.get_spotify_client_for_profile) + # admin / no override -> the global client (admin pipelines unchanged) + assert src._client() is web_server.spotify_client + # a background owner override flows through the resolver, re-resolved per + # call (unconnected profile -> safe global fallback, never a frozen client) + from core.profile_context import set_background_profile, reset_background_profile + tok = set_background_profile(424242) + try: + assert src._client() is web_server.spotify_client + finally: + reset_background_profile(tok) + + +def test_tidal_source_adapter_resolves_per_profile(): + from core.playlists.sources.tidal import TidalPlaylistSource + src = TidalPlaylistSource(web_server.get_tidal_client_for_profile) + assert src._client() is web_server.tidal_client # admin -> global, unchanged diff --git a/web_server.py b/web_server.py index 344d77da..54c47f33 100644 --- a/web_server.py +++ b/web_server.py @@ -1138,8 +1138,14 @@ def _register_automation_handlers(): return match_mb_tracks(tracks, _mb_match_deps) _playlist_source_registry = build_playlist_source_registry( - spotify_client_getter=lambda: spotify_client, - tidal_client_getter=lambda: tidal_client, + # Per-profile source reads: the adapter calls these fresh on every read, + # so they resolve the CURRENT profile's account (their session + # interactively, their automation's owner in the background — via + # core.profile_context). Admin/profile 1 → the global client, so the + # admin's existing auto-sync pipelines are unchanged. (Deezer/Qobuz stay + # global for now — their playlist login is tangled with downloads.) + spotify_client_getter=get_spotify_client_for_profile, + tidal_client_getter=get_tidal_client_for_profile, qobuz_client_getter=_get_qobuz_client_for_sync, deezer_client_getter=_get_deezer_client, youtube_parser=parse_youtube_playlist, From c3ff333934f0f8854f4ff691e445b44cfb21142a Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 15:55:41 -0700 Subject: [PATCH 26/52] tests: make the spotify source-adapter test order-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assert against a monkeypatched get_spotify_client sentinel instead of web_server.spotify_client (which isn't a stable singleton across the suite) — same fix already used for the per-profile builder tests. --- tests/test_credentials_endpoints.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index 592b3973..a4dba6b9 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -322,17 +322,22 @@ def test_real_session_still_wins_over_background(client, nonadmin_profile): # client_getter; these prove that composition resolves per the current profile # context and stays on the global client for admin (the existing pipelines). -def test_spotify_source_adapter_resolves_per_profile(): +def test_spotify_source_adapter_resolves_per_profile(monkeypatch): from core.playlists.sources.spotify import SpotifyPlaylistSource + from core.metadata import registry + # The real global Spotify client isn't a stable singleton across the suite, + # so pin it to a sentinel for an order-independent identity check. + sentinel = object() + monkeypatch.setattr(registry, 'get_spotify_client', lambda *a, **k: sentinel) + registry.register_profile_spotify_credentials_provider(lambda pid: None) src = SpotifyPlaylistSource(web_server.get_spotify_client_for_profile) - # admin / no override -> the global client (admin pipelines unchanged) - assert src._client() is web_server.spotify_client - # a background owner override flows through the resolver, re-resolved per - # call (unconnected profile -> safe global fallback, never a frozen client) + # admin / no override -> the global resolver (the sentinel) + assert src._client() is sentinel + # unconnected background owner override -> safe global fallback, re-resolved per call from core.profile_context import set_background_profile, reset_background_profile tok = set_background_profile(424242) try: - assert src._client() is web_server.spotify_client + assert src._client() is sentinel finally: reset_background_profile(tok) From cc18ec266e21d311dfae482348dbc014b57ae1ec Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 16:17:04 -0700 Subject: [PATCH 27/52] Profiles: visual revamp of the Manage Profiles modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Functionally unchanged — just brought it up to the polish of the rest of the app (My Accounts / Manage Workers style). Same markup hooks + JS bindings, so no behaviour change. - Glassy gradient panel with blur backdrop, rise+fade entrance, soft shadow. - Sticky header with a gradient people-icon badge + subtitle; close button rotates on hover. - Profile rows are cards now: hover lift, and the profile you're signed in as is highlighted (accent ring + a "You" pill). - Role/status shown as pills (Admin / No Downloads / N pages) instead of a dot-joined string. - Edit/Delete are clean SVG icon buttons (was ✏️/🗑️ emoji) with accent/red hover. - Inputs get a focus glow; colour swatches are larger with a check on the selected one. 64 script-split integrity tests pass; all JS-referenced classNames verified present. --- webui/index.html | 2 +- webui/static/init.js | 25 ++-- webui/static/style.css | 282 ++++++++++++++++++++++++++++++++--------- 3 files changed, 239 insertions(+), 70 deletions(-) diff --git a/webui/index.html b/webui/index.html index b2d2ac70..0fdd3e47 100644 --- a/webui/index.html +++ b/webui/index.html @@ -78,7 +78,7 @@ + +
+ +
+ Enable only if SoulSync runs behind nginx / Caddy / Traefik that terminates HTTPS. Trusts the proxy's X-Forwarded-* headers, marks the session cookie HTTPS-only, and adds security headers. Leave OFF for direct or LAN access over http:// — turning it on would make the session cookie HTTPS-only and break plain-HTTP access. Takes effect after a restart. See Support/REVERSE-PROXY.md. +
+
+ +
+ + +
+ If an auth proxy (Authelia / Authentik / oauth2-proxy) logs users in in front of SoulSync, enter the header it sets (e.g. Remote-User) and SoulSync will skip the launch PIN for already-authenticated requests. Only set this behind a proxy that strips any client-supplied copy of the header — otherwise it can be spoofed. Leave blank to disable (the default). +
+
diff --git a/webui/static/settings.js b/webui/static/settings.js index 30288c68..1a1a77ac 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -1398,6 +1398,12 @@ async function loadSettingsData() { const corsField = document.getElementById('security-cors-origins'); if (corsField) corsField.value = corsOrigins; + // Reverse-proxy mode + auth-proxy header (default off / empty). + const trustProxy = document.getElementById('security-trust-proxy'); + if (trustProxy) trustProxy.checked = settings.security?.trust_reverse_proxy || false; + const authHeader = document.getElementById('security-auth-proxy-header'); + if (authHeader) authHeader.value = settings.security?.auth_proxy_header || ''; + // Check if admin has a PIN set const profilesRes = await fetch('/api/profiles'); const profilesData = await profilesRes.json(); @@ -3146,6 +3152,8 @@ async function saveSettings(quiet = false) { security: { require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false, cors_origins: document.getElementById('security-cors-origins')?.value?.trim() || '', + trust_reverse_proxy: document.getElementById('security-trust-proxy')?.checked || false, + auth_proxy_header: document.getElementById('security-auth-proxy-header')?.value?.trim() || '', } }; From 8e1b678d6f45fc473440a259dc93272f632c01a9 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 21:57:44 -0700 Subject: [PATCH 42/52] Native login (increment 1/3): per-profile password DB layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in username/password login — profiles become real accounts. This is the data layer: a per-profile login password, kept SEPARATE from the quick-switch PIN (different security purpose; a 4-digit PIN must not become the password guarding a public instance). - Additive migration: profiles.password_hash column (idempotent, metadata-flagged). - set_profile_password / verify_profile_password / profile_has_password / get_profile_by_name (the login username = profile name, unique + case-insensitive). - Security default: a profile with NO password is NOT loginable (verify returns False) — unlike the PIN where "no PIN = always valid". You can't authenticate to an account with no credential. Tests: migration adds the column; set/verify; no-password-never-loginable; clearing; name lookup; and password is fully independent of the PIN. 6 tests pass. Next: the login endpoint + require_login gate (increment 2). --- database/music_database.py | 80 ++++++++++++++++++++++++++++++++++ tests/test_profile_password.py | 67 ++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/test_profile_password.py diff --git a/database/music_database.py b/database/music_database.py index 3c2ebf86..88f23593 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -460,6 +460,7 @@ class MusicDatabase: self._add_profile_support_v4(cursor) self._add_profile_settings(cursor) self._add_profile_listenbrainz_support(cursor) + self._add_profile_password_support(cursor) self._add_profile_service_credentials(cursor) self._add_service_credential_sets(cursor) self._add_soul_id_columns(cursor) @@ -5375,6 +5376,85 @@ class MusicDatabase: logger.error(f"Error verifying PIN for profile {profile_id}: {e}") return False + # ── Per-profile LOGIN password (opt-in username/password mode) ──────────── + # Separate from the quick-switch PIN on purpose: the PIN is a low-stakes + # convenience on a trusted LAN; the password authenticates an account for + # public exposure. Conflating them would make logins as weak as a 4-digit PIN. + + def set_profile_password(self, profile_id: int, password: str) -> bool: + """Set (or clear, when password is falsy) a profile's login password.""" + try: + from werkzeug.security import generate_password_hash + pwd_hash = generate_password_hash(password, method='pbkdf2:sha256') if password else None + with self._get_connection() as conn: + conn.execute("UPDATE profiles SET password_hash = ? WHERE id = ?", (pwd_hash, profile_id)) + conn.commit() + return True + except Exception as e: + logger.error(f"Error setting password for profile {profile_id}: {e}") + return False + + def verify_profile_password(self, profile_id: int, password: str) -> bool: + """Verify a profile's login password. Unlike the PIN, a profile with NO + password set is NOT loginable (returns False) — you can't authenticate to + an account that has no credential.""" + try: + from werkzeug.security import check_password_hash + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM profiles WHERE id = ?", (profile_id,)) + row = cursor.fetchone() + if not row or not row['password_hash']: + return False # no password set → cannot log in + return check_password_hash(row['password_hash'], password) + except Exception as e: + logger.error(f"Error verifying password for profile {profile_id}: {e}") + return False + + def profile_has_password(self, profile_id: int) -> bool: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM profiles WHERE id = ?", (profile_id,)) + row = cursor.fetchone() + return bool(row and row['password_hash']) + except Exception as e: + logger.error(f"Error checking password for profile {profile_id}: {e}") + return False + + def get_profile_by_name(self, name: str) -> Optional[Dict[str, Any]]: + """Look up a profile by name (the login username), case-insensitive.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT id, name, is_admin FROM profiles WHERE LOWER(name) = LOWER(?)", + (name or '',)) + row = cursor.fetchone() + if not row: + return None + return {'id': row['id'], 'name': row['name'], 'is_admin': bool(row['is_admin'])} + except Exception as e: + logger.error(f"Error looking up profile by name '{name}': {e}") + return None + + def _add_profile_password_support(self, cursor): + """Add a per-profile login password column (separate from pin_hash).""" + try: + cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_password_v1' LIMIT 1") + if cursor.fetchone(): + return # Already migrated + logger.info("Applying per-profile login-password migration...") + try: + cursor.execute("ALTER TABLE profiles ADD COLUMN password_hash TEXT DEFAULT NULL") + except sqlite3.OperationalError: + pass # Column already exists + cursor.execute( + "INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_password_v1', '1')") + logger.info("Per-profile login-password migration completed") + except Exception as e: + logger.error(f"Error in login-password migration: {e}") + def close(self): """Close database connection (no-op since we create connections per operation)""" # Each operation creates and closes its own connection, so nothing to do here diff --git a/tests/test_profile_password.py b/tests/test_profile_password.py new file mode 100644 index 00000000..798e6d91 --- /dev/null +++ b/tests/test_profile_password.py @@ -0,0 +1,67 @@ +"""Per-profile LOGIN password (opt-in username/password mode) — DB layer. + +Separate from the quick-switch PIN: a profile with no password set is NOT +loginable (you can't authenticate to an account with no credential), unlike the +PIN where 'no PIN = always valid'. +""" + +from __future__ import annotations + +import pytest + +from database.music_database import MusicDatabase + + +@pytest.fixture +def db(tmp_path): + return MusicDatabase(str(tmp_path / "music.db")) + + +def test_migration_adds_password_hash_column(db): + with db._get_connection() as conn: + cols = [r[1] for r in conn.execute("PRAGMA table_info(profiles)").fetchall()] + assert 'password_hash' in cols + + +def test_set_and_verify_password(db): + pid = db.create_profile(name='Brock') + assert db.profile_has_password(pid) is False # none yet + assert db.verify_profile_password(pid, 'hunter2') is False # no password → not loginable + + db.set_profile_password(pid, 'hunter2') + assert db.profile_has_password(pid) is True + assert db.verify_profile_password(pid, 'hunter2') is True + assert db.verify_profile_password(pid, 'wrong') is False + + +def test_no_password_is_never_loginable(db): + # Unlike the PIN (no PIN = always valid), a passwordless account can't log in. + pid = db.create_profile(name='NoPass') + assert db.verify_profile_password(pid, '') is False + assert db.verify_profile_password(pid, 'anything') is False + + +def test_clearing_password(db): + pid = db.create_profile(name='Temp') + db.set_profile_password(pid, 'pw') + assert db.profile_has_password(pid) is True + db.set_profile_password(pid, '') # clear + assert db.profile_has_password(pid) is False + assert db.verify_profile_password(pid, 'pw') is False + + +def test_get_profile_by_name_case_insensitive(db): + pid = db.create_profile(name='Daughter') + assert db.get_profile_by_name('daughter')['id'] == pid + assert db.get_profile_by_name('DAUGHTER')['id'] == pid + assert db.get_profile_by_name('nobody') is None + + +def test_password_is_independent_of_pin(db): + # Setting a password must not touch the PIN and vice-versa (separate creds). + from werkzeug.security import generate_password_hash + pid = db.create_profile(name='Both', pin_hash=generate_password_hash('1234', method='pbkdf2:sha256')) + db.set_profile_password(pid, 'longpassword') + assert db.verify_profile_pin(pid, '1234') is True # PIN still works + assert db.verify_profile_password(pid, 'longpassword') is True # password works + assert db.verify_profile_password(pid, '1234') is False # PIN is NOT the password From 92cbef90f9286cb451080845a90bee4c8d642d07 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 22:01:53 -0700 Subject: [PATCH 43/52] Native login (increment 2/3): login/logout endpoints + require_login gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend auth for opt-in username/password mode (security.require_login, default off → zero change; the launch PIN + picker behave exactly as today). - core/security/login_gate.py: pure gate (mirrors launch_lock) — when login mode is on, an unauthenticated session reaches only the page shell, /api/auth/login, /api/auth/logout, /api/profiles/current, /api/setup/status, and the key-authed /api/v1 API. Deliberately does NOT expose the profile list pre-auth (you type your name, not pick from a roster). - _enforce_login before_request enforces it; _enforce_launch_pin no-ops when login mode is on (login replaces the shared PIN, per design). - POST /api/auth/login (username = profile name, case-insensitive; brute-force limited per IP; generic error so names don't leak) + POST /api/auth/logout. - Anti-lockout: the settings save refuses to turn ON login mode until the admin account has a password. Tests: gate blocks→login→access→logout→blocked; case-insensitive username; wrong password / passwordless profile / unknown user all 401 generically; login list not exposed pre-auth; can't enable login without an admin password. 12 tests pass. Next: the login screen + set-password UI + the toggle (increment 3). --- core/security/login_gate.py | 53 ++++++++++++++++++++ tests/test_login_endpoints.py | 82 ++++++++++++++++++++++++++++++ tests/test_login_gate.py | 36 ++++++++++++++ web_server.py | 93 +++++++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 core/security/login_gate.py create mode 100644 tests/test_login_endpoints.py create mode 100644 tests/test_login_gate.py diff --git a/core/security/login_gate.py b/core/security/login_gate.py new file mode 100644 index 00000000..bf521df4 --- /dev/null +++ b/core/security/login_gate.py @@ -0,0 +1,53 @@ +"""Pure gate decision for opt-in username/password login mode. + +When ``security.require_login`` is on, every request must come from an +authenticated session; unauthenticated requests are blocked except the page shell, +the login/logout flow, and the key-authed public API. This is the per-user +equivalent of (and replacement for) the shared launch-PIN gate. + +Deliberately does NOT allowlist the profile LIST or picker — in login mode you log +in by typing your name + password, you don't pick from an exposed roster. +""" + +from __future__ import annotations + +# GET endpoints the login screen itself needs before auth. +_ALLOWED_GET = frozenset({ + '/api/profiles/current', # how the frontend detects login state + '/api/setup/status', # first-run check runs before the login screen +}) + +# POST endpoints that drive the login flow. +_ALLOWED_POST = frozenset({ + '/api/auth/login', + '/api/auth/logout', +}) + + +def login_request_is_blocked(path: str, method: str, *, + require_login: bool, authenticated: bool) -> bool: + """True when the login gate must reject this request (login mode on + the + session isn't authenticated and the path isn't part of the login flow).""" + if not require_login or authenticated: + return False + + path = path or '' + method = (method or 'GET').upper() + + # Page shell + assets needed to render the login screen. + if path == '/' or path.startswith('/static/') or path.startswith('/favicon'): + return False + + # Key-authed public API governs itself (its own key auth). + if path.startswith('/api/v1/') and not path.startswith('/api/v1/api-keys-internal'): + return False + + if method == 'GET' and path in _ALLOWED_GET: + return False + if method == 'POST' and path in _ALLOWED_POST: + return False + + return True + + +__all__ = ['login_request_is_blocked'] diff --git a/tests/test_login_endpoints.py b/tests/test_login_endpoints.py new file mode 100644 index 00000000..4b9854b0 --- /dev/null +++ b/tests/test_login_endpoints.py @@ -0,0 +1,82 @@ +"""Username/password login endpoints + gate (opt-in login mode).""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-login-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'l.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' + +web_server = pytest.importorskip('web_server') + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +def _enable_login(monkeypatch): + real_get = web_server.config_manager.get + monkeypatch.setattr(web_server.config_manager, 'get', + lambda k, d=None: True if k == 'security.require_login' else real_get(k, d)) + web_server._login_limiter.record_success('127.0.0.1') # clean slate + + +_GATED = '/api/profiles/me/connections' # a normal, non-allowlisted endpoint + + +def test_login_gate_blocks_then_authenticated_access(client, monkeypatch): + db = web_server.get_database() + pid = db.create_profile(name='LoginUser') + db.set_profile_password(pid, 'secretpw') + _enable_login(monkeypatch) + + assert client.get(_GATED).status_code == 401 # not logged in → blocked + + r = client.post('/api/auth/login', json={'username': 'LoginUser', 'password': 'secretpw'}) + assert r.status_code == 200 and r.get_json()['success'] is True + + assert client.get(_GATED).status_code == 200 # authenticated → in + + assert client.post('/api/auth/logout').get_json()['success'] is True + assert client.get(_GATED).status_code == 401 # logged out → blocked again + + +def test_login_is_case_insensitive_on_username(client, monkeypatch): + db = web_server.get_database() + db.set_profile_password(db.create_profile(name='CaseUser'), 'pw') + _enable_login(monkeypatch) + assert client.post('/api/auth/login', json={'username': 'caseuser', 'password': 'pw'}).status_code == 200 + + +def test_wrong_password_401_generic(client, monkeypatch): + db = web_server.get_database() + db.set_profile_password(db.create_profile(name='WrongPwUser'), 'right') + _enable_login(monkeypatch) + r = client.post('/api/auth/login', json={'username': 'WrongPwUser', 'password': 'nope'}) + assert r.status_code == 401 + assert 'username or password' in r.get_json()['error'].lower() # generic — no name-leak + + +def test_passwordless_profile_cannot_login(client, monkeypatch): + db = web_server.get_database() + db.create_profile(name='NoPwUser') # no password set + _enable_login(monkeypatch) + assert client.post('/api/auth/login', json={'username': 'NoPwUser', 'password': 'x'}).status_code == 401 + + +def test_unknown_user_401(client, monkeypatch): + _enable_login(monkeypatch) + assert client.post('/api/auth/login', json={'username': 'ghost', 'password': 'x'}).status_code == 401 + + +def test_cannot_enable_login_without_admin_password(client): + # admin (1) has no password → enabling login mode is refused (anti-lockout) + web_server.get_database().set_profile_password(1, '') + r = client.post('/api/settings', json={'security': {'require_login': True}}) + assert r.status_code == 400 + assert 'password' in r.get_json().get('error', '').lower() diff --git a/tests/test_login_gate.py b/tests/test_login_gate.py new file mode 100644 index 00000000..51e9699e --- /dev/null +++ b/tests/test_login_gate.py @@ -0,0 +1,36 @@ +"""Pure login-gate decision (opt-in username/password mode).""" + +from __future__ import annotations + +from core.security.login_gate import login_request_is_blocked as blocked + + +def test_off_never_blocks(): + assert blocked('/api/anything', 'GET', require_login=False, authenticated=False) is False + + +def test_authenticated_never_blocked(): + assert blocked('/api/anything', 'GET', require_login=True, authenticated=True) is False + + +def test_unauthenticated_blocked_on_normal_api(): + assert blocked('/api/library', 'GET', require_login=True, authenticated=False) is True + + +def test_login_flow_and_shell_allowed_unauthenticated(): + for p in ('/', '/static/app.js', '/favicon.ico'): + assert blocked(p, 'GET', require_login=True, authenticated=False) is False + assert blocked('/api/auth/login', 'POST', require_login=True, authenticated=False) is False + assert blocked('/api/auth/logout', 'POST', require_login=True, authenticated=False) is False + assert blocked('/api/profiles/current', 'GET', require_login=True, authenticated=False) is False + assert blocked('/api/setup/status', 'GET', require_login=True, authenticated=False) is False + + +def test_profile_list_NOT_exposed_pre_auth(): + # login mode = type your name, don't pick from an exposed roster + assert blocked('/api/profiles', 'GET', require_login=True, authenticated=False) is True + + +def test_key_authed_public_api_allowed(): + assert blocked('/api/v1/search', 'GET', require_login=True, authenticated=False) is False + assert blocked('/api/v1/api-keys-internal', 'GET', require_login=True, authenticated=False) is True diff --git a/web_server.py b/web_server.py index c85c46f4..8345b3ee 100644 --- a/web_server.py +++ b/web_server.py @@ -402,6 +402,36 @@ def inject_webui_assets(): # PINs from one IP trips it — correct entry clears it instantly). from core.security.rate_limit import AttemptLimiter as _AttemptLimiter _launch_pin_limiter = _AttemptLimiter(max_attempts=10, window_seconds=300) +_login_limiter = _AttemptLimiter(max_attempts=10, window_seconds=300) + + +def _require_login_enabled(): + try: + return bool(config_manager.get('security.require_login', False)) if config_manager else False + except Exception: + return False + + +# --- Login gate (opt-in username/password mode; replaces the launch PIN) --- +@app.before_request +def _enforce_login(): + """Server-side enforcement of username/password login. No-op unless + security.require_login is on. When on, an unauthenticated session can only + reach the page shell + the login flow + the key-authed public API.""" + if not _require_login_enabled(): + return + from core.security.login_gate import login_request_is_blocked + from core.security.launch_lock import is_html_navigation + if login_request_is_blocked( + request.path, request.method, + require_login=True, + authenticated=bool(session.get('login_authenticated', False)), + ): + if is_html_navigation(request.method, request.headers.get('Accept', ''), + request.headers.get('Sec-Fetch-Mode', '')): + return redirect('/') + return jsonify({"error": "login_required", "login_required": True}), 401 + # --- Launch PIN gate (before_request hook) --- @app.before_request @@ -414,6 +444,10 @@ def _enforce_launch_pin(): on, except the page shell + the unlock flow + the key-authed public API. No-ops entirely when ``security.require_pin_on_launch`` is off (the default). """ + # Login mode replaces the launch PIN entirely — when it's on, _enforce_login + # owns the gate and this no-ops. + if _require_login_enabled(): + return try: require_pin = bool(config_manager.get('security.require_pin_on_launch', False)) if config_manager else False except Exception: @@ -3079,6 +3113,14 @@ def handle_settings(): if not new_settings: return jsonify({"success": False, "error": "No data received."}), 400 + # Anti-lockout: refuse to turn ON login mode until the admin account + # has a password — otherwise enabling it would lock everyone out. + _sec_in = new_settings.get('security') or {} + if _sec_in.get('require_login') and not config_manager.get('security.require_login', False): + if not get_database().profile_has_password(1): + return jsonify({"success": False, + "error": "Set an admin password before enabling login mode."}), 400 + if 'active_media_server' in new_settings: config_manager.set_active_media_server(new_settings['active_media_server']) @@ -25297,6 +25339,57 @@ def verify_launch_pin(): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/auth/login', methods=['POST']) +def auth_login(): + """Username/password login (opt-in login mode). Username = profile name. + Brute-force limited per IP; a profile with no password set can't log in.""" + try: + _ip = request.remote_addr or 'unknown' + _now = time.time() + _locked, _retry_after = _login_limiter.is_locked(_ip, _now) + if _locked: + return (jsonify({'success': False, 'error': 'Too many attempts — please wait and try again'}), + 429, {'Retry-After': str(_retry_after)}) + + data = request.json or {} + username = (data.get('username') or '').strip() + password = data.get('password') or '' + if not username or not password: + return jsonify({'success': False, 'error': 'Username and password required'}), 400 + + database = get_database() + profile = database.get_profile_by_name(username) + # Same generic error + a recorded failure whether the name or password is + # wrong — don't leak which names exist. + if not profile or not database.verify_profile_password(profile['id'], password): + _login_limiter.record_failure(_ip, _now) + return jsonify({'success': False, 'error': 'Invalid username or password'}), 401 + + _login_limiter.record_success(_ip) + session['login_authenticated'] = True + session['profile_id'] = profile['id'] + # A fresh login also clears any stale launch-PIN flag. + session.pop('launch_pin_verified', None) + return jsonify({'success': True, 'profile': { + 'id': profile['id'], 'name': profile['name'], 'is_admin': profile['is_admin'], + }}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/auth/logout', methods=['POST']) +def auth_logout(): + """Log out — clears the authenticated session.""" + try: + session.pop('login_authenticated', None) + session.pop('profile_id', None) + session.pop('launch_pin_verified', None) + return jsonify({'success': True}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/profiles/reset-pin-via-credential', methods=['POST']) def reset_pin_via_credential(): """Reset admin PIN by verifying a known API credential""" From 21dfbb39b0c9bd63f1228aee709d28fcdc4ec2eb Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 22:10:33 -0700 Subject: [PATCH 44/52] Native login (increment 3/3): login screen, set-password, Settings toggle, logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI that makes opt-in login usable. Off by default → your LAN setup is unchanged (none of this appears unless security.require_login is on). - Login screen overlay (reuses the launch-PIN styling): username + password → /api/auth/login → reload into the app. Shown when /api/profiles/current reports login_required (checked before profile selection). - POST /api/profiles//set-password (admin, or self) to set/clear a login password, distinct from the PIN. - Settings → Security: "Login password (admin account)" field + a "Require login" toggle (with the anti-lockout note). Wired into the existing settings load/save. - Sign-out button in the profile bar, revealed only in login mode (login_mode flag on /api/profiles/current); soulsyncLogout() → /api/auth/logout → reload. Tests: set-password sets/clears + verifies; /api/profiles/current signals login_required. 20 login/password tests pass; 64 script-split integrity pass. Remaining (small follow-up): a password field in the Manage Profiles edit form so admins can set OTHER profiles' passwords from the UI (the endpoint already exists). --- tests/test_login_endpoints.py | 18 +++++++++ web_server.py | 25 ++++++++++++ webui/index.html | 38 ++++++++++++++++++ webui/static/init.js | 76 +++++++++++++++++++++++++++++++++++ webui/static/settings.js | 3 ++ 5 files changed, 160 insertions(+) diff --git a/tests/test_login_endpoints.py b/tests/test_login_endpoints.py index 4b9854b0..f943a9ca 100644 --- a/tests/test_login_endpoints.py +++ b/tests/test_login_endpoints.py @@ -80,3 +80,21 @@ def test_cannot_enable_login_without_admin_password(client): r = client.post('/api/settings', json={'security': {'require_login': True}}) assert r.status_code == 400 assert 'password' in r.get_json().get('error', '').lower() + + +def test_set_password_endpoint(client): + db = web_server.get_database() + pid = db.create_profile(name='SetPwTest') + # admin (default session) can set any profile's login password + r = client.post(f'/api/profiles/{pid}/set-password', json={'password': 'newpw123'}) + body = r.get_json() + assert body['success'] is True and body['has_password'] is True + assert db.verify_profile_password(pid, 'newpw123') is True + # clearing it + assert client.post(f'/api/profiles/{pid}/set-password', json={'password': ''}).get_json()['has_password'] is False + + +def test_profiles_current_signals_login_required(client, monkeypatch): + _enable_login(monkeypatch) + body = client.get('/api/profiles/current').get_json() + assert body.get('login_required') is True # frontend uses this to show the sign-in screen diff --git a/web_server.py b/web_server.py index 8345b3ee..3f2e3866 100644 --- a/web_server.py +++ b/web_server.py @@ -25282,6 +25282,12 @@ def select_profile(): def get_current_profile(): """Get the currently selected profile from session""" try: + # Login mode: when on and the session isn't authenticated, tell the + # frontend to show the sign-in screen (this is checked before profile + # selection, since there's no profile until you log in). + if _require_login_enabled() and not session.get('login_authenticated', False): + return jsonify({'success': False, 'login_required': True}), 200 + pid = session.get('profile_id') if not pid: return jsonify({'success': False, 'error': 'No profile selected'}), 200 @@ -25305,6 +25311,7 @@ def get_current_profile(): 'success': True, 'profile': profile, 'launch_pin_required': bool(require_pin) and not pin_verified, + 'login_mode': _require_login_enabled(), }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @@ -25469,6 +25476,24 @@ def set_profile_pin(profile_id): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles//set-password', methods=['POST']) +def set_profile_password_endpoint(profile_id): + """Set or clear a profile's LOGIN password (admin, or the profile itself). + Distinct from the quick-switch PIN.""" + try: + database = get_database() + current_pid = get_current_profile_id() + current = database.get_profile(current_pid) + if not current or (not current['is_admin'] and current_pid != profile_id): + return jsonify({'success': False, 'error': 'Unauthorized'}), 403 + data = request.json or {} + password = data.get('password', '') + ok = database.set_profile_password(profile_id, password) + return jsonify({'success': bool(ok), 'has_password': database.profile_has_password(profile_id)}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + # --- Per-Profile ListenBrainz Settings --- def _get_lb_credentials_for_profile(profile_id=None): diff --git a/webui/index.html b/webui/index.html index 3d90c7e6..4bbf1510 100644 --- a/webui/index.html +++ b/webui/index.html @@ -51,6 +51,19 @@
+ + + @@ -6011,6 +6027,28 @@ If an auth proxy (Authelia / Authentik / oauth2-proxy) logs users in in front of SoulSync, enter the header it sets (e.g. Remote-User) and SoulSync will skip the launch PIN for already-authenticated requests. Only set this behind a proxy that strips any client-supplied copy of the header — otherwise it can be spoofed. Leave blank to disable (the default). + +
+ +
+ +
+ Set a password for the admin account, then turn on "Require login" below. Your username is your profile name. Set passwords for other profiles in Manage Profiles. +
+ + + +
+ +
+ +
+ When enabled, a sign-in screen replaces the profile picker + launch PIN — everyone signs in with their account name + password. Set the admin password above first (you can't enable this without one, to avoid locking yourself out). Best for instances exposed to the internet. +
+
diff --git a/webui/static/init.js b/webui/static/init.js index 71f94496..a032bc4b 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -340,9 +340,21 @@ async function initProfileSystem() { // Check if a session already has a profile selected const currentRes = await fetch('/api/profiles/current'); const currentData = await currentRes.json(); + // Login mode: show the sign-in screen and defer everything else until + // the user authenticates. + if (currentData.login_required) { + showLoginScreen(); + return false; + } if (currentData.success && currentData.profile) { setCurrentProfile(currentData.profile); + // Login mode → reveal the Sign out button in the profile bar. + if (currentData.login_mode) { + const lb = document.getElementById('logout-btn'); + if (lb) lb.style.display = ''; + } + // Check if launch PIN is required if (currentData.launch_pin_required) { showLaunchPinScreen(); @@ -387,6 +399,48 @@ async function initProfileSystem() { } } +// ── Login Screen (username/password mode) ────────────────────────────── + +function showLoginScreen() { + const overlay = document.getElementById('login-overlay'); + if (!overlay) return; + overlay.style.display = 'flex'; + const u = document.getElementById('login-username'); + if (u) setTimeout(() => u.focus(), 50); +} + +async function submitLogin() { + const username = (document.getElementById('login-username')?.value || '').trim(); + const password = document.getElementById('login-password')?.value || ''; + const errEl = document.getElementById('login-error'); + const btn = document.getElementById('login-submit'); + const showErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = 'block'; } }; + if (errEl) errEl.style.display = 'none'; + if (!username || !password) { showErr('Enter your username and password'); return; } + if (btn) { btn.disabled = true; btn.textContent = 'Signing in...'; } + try { + const res = await fetch('/api/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json(); + if (data.success) { + window.location.reload(); // authenticated → reload into the app + } else { + showErr(res.status === 429 ? 'Too many attempts — wait a moment.' : (data.error || 'Sign in failed')); + if (btn) { btn.disabled = false; btn.textContent = 'Sign in'; } + } + } catch (e) { + showErr('Connection error'); + if (btn) { btn.disabled = false; btn.textContent = 'Sign in'; } + } +} + +async function soulsyncLogout() { + try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) { /* reload anyway */ } + window.location.reload(); +} + // ── Launch PIN Lock Screen ───────────────────────────────────────────── function showLaunchPinScreen() { @@ -451,6 +505,28 @@ function showLaunchPinScreen() { // ── Security Settings Helpers ────────────────────────────────────────── +async function saveLoginPassword() { + const input = document.getElementById('security-login-password'); + const msg = document.getElementById('security-login-password-msg'); + const password = input?.value || ''; + const show = (text, ok) => { + if (!msg) return; + msg.textContent = text; + msg.style.color = ok ? '#4caf50' : '#ff5252'; + msg.style.display = 'block'; + }; + if (!password || password.length < 6) { show('Password must be at least 6 characters', false); return; } + try { + const res = await fetch('/api/profiles/1/set-password', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + const data = await res.json(); + if (data.success) { show('Admin login password saved', true); if (input) input.value = ''; } + else show(data.error || 'Failed to save password', false); + } catch (e) { show('Connection error', false); } +} + async function saveSecurityPin() { const pin = document.getElementById('security-new-pin').value; const confirm = document.getElementById('security-confirm-pin').value; diff --git a/webui/static/settings.js b/webui/static/settings.js index 1a1a77ac..3a5967b6 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -1403,6 +1403,8 @@ async function loadSettingsData() { if (trustProxy) trustProxy.checked = settings.security?.trust_reverse_proxy || false; const authHeader = document.getElementById('security-auth-proxy-header'); if (authHeader) authHeader.value = settings.security?.auth_proxy_header || ''; + const reqLogin = document.getElementById('security-require-login'); + if (reqLogin) reqLogin.checked = settings.security?.require_login || false; // Check if admin has a PIN set const profilesRes = await fetch('/api/profiles'); @@ -3154,6 +3156,7 @@ async function saveSettings(quiet = false) { cors_origins: document.getElementById('security-cors-origins')?.value?.trim() || '', trust_reverse_proxy: document.getElementById('security-trust-proxy')?.checked || false, auth_proxy_header: document.getElementById('security-auth-proxy-header')?.value?.trim() || '', + require_login: document.getElementById('security-require-login')?.checked || false, } }; From 9e40f5c12d9b6b0b365c4f08b56aaa3731aae4c9 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 22:19:29 -0700 Subject: [PATCH 45/52] =?UTF-8?q?tests:=20pin/login=20mode=20isolation=20?= =?UTF-8?q?=E2=80=94=20PIN=20gate=20unaffected=20when=20login=20off;=20bot?= =?UTF-8?q?h-off=20=3D=20unguarded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the zero-impact guarantee: with require_login off (default), the launch PIN still enforces exactly as before (the login deferral doesn't fire), and with both off there's no gate at all (today's behavior). --- tests/test_login_endpoints.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_login_endpoints.py b/tests/test_login_endpoints.py index f943a9ca..2441c5c5 100644 --- a/tests/test_login_endpoints.py +++ b/tests/test_login_endpoints.py @@ -98,3 +98,30 @@ def test_profiles_current_signals_login_required(client, monkeypatch): _enable_login(monkeypatch) body = client.get('/api/profiles/current').get_json() assert body.get('login_required') is True # frontend uses this to show the sign-in screen + + +def test_pin_gate_unaffected_when_login_off(client, monkeypatch): + # THE guarantee: with login mode OFF (default) and the launch PIN ON, the PIN + # gate must STILL enforce — the login feature must not weaken or bypass it. + real_get = web_server.config_manager.get + def fake_get(key, default=None): + if key == 'security.require_login': + return False # login OFF (default) + if key == 'security.require_pin_on_launch': + return True # PIN ON + return real_get(key, default) + monkeypatch.setattr(web_server.config_manager, 'get', fake_get) + + # Unverified session, PIN required → the launch-PIN gate still 401s. + assert client.get('/api/profiles/me/connections').status_code == 401 + # And /api/profiles/current reports the PIN screen, NOT login. + body = client.get('/api/profiles/current').get_json() + assert body.get('login_required') is not True + + +def test_everything_normal_when_both_off(client, monkeypatch): + # Default install: login OFF + PIN OFF → no gate at all (today's behavior). + real_get = web_server.config_manager.get + monkeypatch.setattr(web_server.config_manager, 'get', + lambda k, d=None: False if k in ('security.require_login', 'security.require_pin_on_launch') else real_get(k, d)) + assert client.get('/api/profiles/me/connections').status_code == 200 # reachable, unguarded From 613688a9ad6d815aea92e6ce7b810caf1a60a2c4 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 22:24:54 -0700 Subject: [PATCH 46/52] Login recovery (DB + backend): security question to reset a forgotten password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the forgot-login-password gap. A per-profile recovery question + answer lets a locked-out user reset their own password. - DB: additive recovery_question + recovery_answer_hash columns (idempotent migration). set/get-question/verify/has methods; answer is hashed (pbkdf2) and matched forgivingly (trim + lowercase + collapse whitespace). No recovery set → never verifies. - Endpoints (allowlisted in the login gate so they work pre-auth): GET /api/auth/recovery-question?username= (generic 404 when absent), POST /api/auth/recovery-reset {username, answer, new_password} — brute-force limited; a correct answer sets the new password + authenticates the session. POST /api/profiles//set-recovery (admin or self) to configure it. Tests: set/get/verify, forgiving match, hashed-not-plaintext, no-recovery-never- verifies, full reset flow (wrong answer rejected + password intact; correct answer resets), unknown-user 404. 25 tests pass. Next: the Settings + login-screen UI. --- core/security/login_gate.py | 6 ++- database/music_database.py | 88 ++++++++++++++++++++++++++++++++++ tests/test_login_endpoints.py | 38 +++++++++++++++ tests/test_profile_recovery.py | 60 +++++++++++++++++++++++ web_server.py | 69 ++++++++++++++++++++++++++ 5 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 tests/test_profile_recovery.py diff --git a/core/security/login_gate.py b/core/security/login_gate.py index bf521df4..87fb0375 100644 --- a/core/security/login_gate.py +++ b/core/security/login_gate.py @@ -13,14 +13,16 @@ from __future__ import annotations # GET endpoints the login screen itself needs before auth. _ALLOWED_GET = frozenset({ - '/api/profiles/current', # how the frontend detects login state - '/api/setup/status', # first-run check runs before the login screen + '/api/profiles/current', # how the frontend detects login state + '/api/setup/status', # first-run check runs before the login screen + '/api/auth/recovery-question', # forgot-password: fetch the security question }) # POST endpoints that drive the login flow. _ALLOWED_POST = frozenset({ '/api/auth/login', '/api/auth/logout', + '/api/auth/recovery-reset', # forgot-password: answer + set a new password }) diff --git a/database/music_database.py b/database/music_database.py index 88f23593..5816065c 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -461,6 +461,7 @@ class MusicDatabase: self._add_profile_settings(cursor) self._add_profile_listenbrainz_support(cursor) self._add_profile_password_support(cursor) + self._add_profile_recovery_support(cursor) self._add_profile_service_credentials(cursor) self._add_service_credential_sets(cursor) self._add_soul_id_columns(cursor) @@ -5455,6 +5456,93 @@ class MusicDatabase: except Exception as e: logger.error(f"Error in login-password migration: {e}") + # ── Login-password recovery (security question + answer) ────────────────── + + @staticmethod + def _normalize_recovery_answer(answer: str) -> str: + """Forgiving match: trim + lowercase + collapse internal whitespace.""" + return ' '.join((answer or '').strip().lower().split()) + + def set_profile_recovery(self, profile_id: int, question: str, answer: str) -> bool: + """Set (or clear, when either is empty) a profile's recovery Q + answer.""" + try: + from werkzeug.security import generate_password_hash + q = (question or '').strip() + norm = self._normalize_recovery_answer(answer) + if not q or not norm: + question_val, answer_hash = None, None # clear + else: + question_val = q + answer_hash = generate_password_hash(norm, method='pbkdf2:sha256') + with self._get_connection() as conn: + conn.execute( + "UPDATE profiles SET recovery_question = ?, recovery_answer_hash = ? WHERE id = ?", + (question_val, answer_hash, profile_id)) + conn.commit() + return True + except Exception as e: + logger.error(f"Error setting recovery for profile {profile_id}: {e}") + return False + + def get_profile_recovery_question(self, profile_id: int) -> Optional[str]: + """The recovery question text, or None if none set.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT recovery_question FROM profiles WHERE id = ?", (profile_id,)) + row = cursor.fetchone() + return row['recovery_question'] if row and row['recovery_question'] else None + except Exception as e: + logger.error(f"Error reading recovery question for profile {profile_id}: {e}") + return None + + def verify_profile_recovery_answer(self, profile_id: int, answer: str) -> bool: + """Verify the recovery answer. No recovery set → never verifies.""" + try: + from werkzeug.security import check_password_hash + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT recovery_answer_hash FROM profiles WHERE id = ?", (profile_id,)) + row = cursor.fetchone() + if not row or not row['recovery_answer_hash']: + return False + return check_password_hash(row['recovery_answer_hash'], self._normalize_recovery_answer(answer)) + except Exception as e: + logger.error(f"Error verifying recovery answer for profile {profile_id}: {e}") + return False + + def profile_has_recovery(self, profile_id: int) -> bool: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT recovery_answer_hash FROM profiles WHERE id = ?", (profile_id,)) + row = cursor.fetchone() + return bool(row and row['recovery_answer_hash']) + except Exception as e: + logger.error(f"Error checking recovery for profile {profile_id}: {e}") + return False + + def _add_profile_recovery_support(self, cursor): + """Add recovery question + answer-hash columns (login-password recovery).""" + try: + cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_recovery_v1' LIMIT 1") + if cursor.fetchone(): + return # Already migrated + logger.info("Applying per-profile recovery-question migration...") + for col_sql in ( + "ALTER TABLE profiles ADD COLUMN recovery_question TEXT DEFAULT NULL", + "ALTER TABLE profiles ADD COLUMN recovery_answer_hash TEXT DEFAULT NULL", + ): + try: + cursor.execute(col_sql) + except sqlite3.OperationalError: + pass # Column already exists + cursor.execute( + "INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_recovery_v1', '1')") + logger.info("Per-profile recovery-question migration completed") + except Exception as e: + logger.error(f"Error in recovery-question migration: {e}") + def close(self): """Close database connection (no-op since we create connections per operation)""" # Each operation creates and closes its own connection, so nothing to do here diff --git a/tests/test_login_endpoints.py b/tests/test_login_endpoints.py index 2441c5c5..390b8d3b 100644 --- a/tests/test_login_endpoints.py +++ b/tests/test_login_endpoints.py @@ -125,3 +125,41 @@ def test_everything_normal_when_both_off(client, monkeypatch): monkeypatch.setattr(web_server.config_manager, 'get', lambda k, d=None: False if k in ('security.require_login', 'security.require_pin_on_launch') else real_get(k, d)) assert client.get('/api/profiles/me/connections').status_code == 200 # reachable, unguarded + + +def test_recovery_flow_resets_password(client, monkeypatch): + db = web_server.get_database() + pid = db.create_profile(name='RecoverMe') + db.set_profile_password(pid, 'oldpassword') + db.set_profile_recovery(pid, 'First pet?', 'Rex') + _enable_login(monkeypatch) + + # forgot-password flow is reachable pre-auth + q = client.get('/api/auth/recovery-question?username=RecoverMe').get_json() + assert q['success'] and q['question'] == 'First pet?' + + # wrong answer → 401, password unchanged + bad = client.post('/api/auth/recovery-reset', + json={'username': 'RecoverMe', 'answer': 'Fido', 'new_password': 'newpass1'}) + assert bad.status_code == 401 + assert db.verify_profile_password(pid, 'oldpassword') is True + + # correct answer → password reset + authenticated + ok = client.post('/api/auth/recovery-reset', + json={'username': 'RecoverMe', 'answer': 'rex', 'new_password': 'brandnew1'}) + assert ok.status_code == 200 and ok.get_json()['success'] is True + assert db.verify_profile_password(pid, 'brandnew1') is True + assert db.verify_profile_password(pid, 'oldpassword') is False + + +def test_recovery_question_404_for_unknown(client, monkeypatch): + _enable_login(monkeypatch) + assert client.get('/api/auth/recovery-question?username=ghost').status_code == 404 + + +def test_set_recovery_endpoint(client): + db = web_server.get_database() + pid = db.create_profile(name='SetRec') + r = client.post(f'/api/profiles/{pid}/set-recovery', json={'question': 'Q?', 'answer': 'A'}) + assert r.get_json()['has_recovery'] is True + assert db.verify_profile_recovery_answer(pid, 'a') is True diff --git a/tests/test_profile_recovery.py b/tests/test_profile_recovery.py new file mode 100644 index 00000000..f730b74f --- /dev/null +++ b/tests/test_profile_recovery.py @@ -0,0 +1,60 @@ +"""Login-password recovery via security question + answer — DB layer.""" + +from __future__ import annotations + +import pytest + +from database.music_database import MusicDatabase + + +@pytest.fixture +def db(tmp_path): + return MusicDatabase(str(tmp_path / "music.db")) + + +def test_migration_adds_recovery_columns(db): + with db._get_connection() as conn: + cols = [r[1] for r in conn.execute("PRAGMA table_info(profiles)").fetchall()] + assert 'recovery_question' in cols and 'recovery_answer_hash' in cols + + +def test_set_get_verify(db): + pid = db.create_profile(name='RecUser') + assert db.profile_has_recovery(pid) is False + assert db.get_profile_recovery_question(pid) is None + + db.set_profile_recovery(pid, 'First pet?', 'Rex') + assert db.profile_has_recovery(pid) is True + assert db.get_profile_recovery_question(pid) == 'First pet?' + assert db.verify_profile_recovery_answer(pid, 'Rex') is True + assert db.verify_profile_recovery_answer(pid, 'Fido') is False + + +def test_answer_match_is_forgiving(db): + pid = db.create_profile(name='Forgiving') + db.set_profile_recovery(pid, 'City?', ' New York ') + assert db.verify_profile_recovery_answer(pid, 'new york') is True # case + spacing + assert db.verify_profile_recovery_answer(pid, 'NEW YORK') is True + + +def test_no_recovery_never_verifies(db): + pid = db.create_profile(name='NoRec') + assert db.verify_profile_recovery_answer(pid, '') is False + assert db.verify_profile_recovery_answer(pid, 'anything') is False + + +def test_clearing_recovery(db): + pid = db.create_profile(name='ClearRec') + db.set_profile_recovery(pid, 'Q?', 'A') + assert db.profile_has_recovery(pid) is True + db.set_profile_recovery(pid, '', '') + assert db.profile_has_recovery(pid) is False + assert db.get_profile_recovery_question(pid) is None + + +def test_answer_is_hashed_not_plaintext(db): + pid = db.create_profile(name='Hashed') + db.set_profile_recovery(pid, 'Q?', 'secretanswer') + with db._get_connection() as conn: + stored = conn.execute("SELECT recovery_answer_hash FROM profiles WHERE id = ?", (pid,)).fetchone()[0] + assert 'secretanswer' not in stored and stored.startswith('pbkdf2:') diff --git a/web_server.py b/web_server.py index 3f2e3866..78557973 100644 --- a/web_server.py +++ b/web_server.py @@ -25397,6 +25397,75 @@ def auth_logout(): return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/auth/recovery-question', methods=['GET']) +def auth_recovery_question(): + """Return the recovery security-question for a username (forgot-password flow). + Generic when the user/question is absent — don't confirm which names exist.""" + try: + username = (request.args.get('username') or '').strip() + database = get_database() + profile = database.get_profile_by_name(username) if username else None + question = database.get_profile_recovery_question(profile['id']) if profile else None + if not question: + return jsonify({'success': False, 'error': 'No recovery question available'}), 404 + return jsonify({'success': True, 'question': question}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/auth/recovery-reset', methods=['POST']) +def auth_recovery_reset(): + """Reset a login password by answering the recovery question. Brute-force + limited; a correct answer sets the new password and authenticates the session.""" + try: + _ip = request.remote_addr or 'unknown' + _now = time.time() + _locked, _retry_after = _login_limiter.is_locked(_ip, _now) + if _locked: + return (jsonify({'success': False, 'error': 'Too many attempts — please wait and try again'}), + 429, {'Retry-After': str(_retry_after)}) + + data = request.json or {} + username = (data.get('username') or '').strip() + answer = data.get('answer') or '' + new_password = data.get('new_password') or '' + if not username or not answer or not new_password: + return jsonify({'success': False, 'error': 'Username, answer and new password are required'}), 400 + if len(new_password) < 6: + return jsonify({'success': False, 'error': 'New password must be at least 6 characters'}), 400 + + database = get_database() + profile = database.get_profile_by_name(username) + if not profile or not database.verify_profile_recovery_answer(profile['id'], answer): + _login_limiter.record_failure(_ip, _now) + return jsonify({'success': False, 'error': 'Incorrect answer'}), 401 + + _login_limiter.record_success(_ip) + database.set_profile_password(profile['id'], new_password) + session['login_authenticated'] = True + session['profile_id'] = profile['id'] + session.pop('launch_pin_verified', None) + return jsonify({'success': True}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/profiles//set-recovery', methods=['POST']) +def set_profile_recovery_endpoint(profile_id): + """Set or clear a profile's recovery question + answer (admin, or self).""" + try: + database = get_database() + current_pid = get_current_profile_id() + current = database.get_profile(current_pid) + if not current or (not current['is_admin'] and current_pid != profile_id): + return jsonify({'success': False, 'error': 'Unauthorized'}), 403 + data = request.json or {} + ok = database.set_profile_recovery(profile_id, data.get('question', ''), data.get('answer', '')) + return jsonify({'success': bool(ok), 'has_recovery': database.profile_has_recovery(profile_id)}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/profiles/reset-pin-via-credential', methods=['POST']) def reset_pin_via_credential(): """Reset admin PIN by verifying a known API credential""" From 5c80ee1010d87e3efbe06c75039486043e8a1862 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 22:28:07 -0700 Subject: [PATCH 47/52] Login recovery (UI): Settings setup + "Forgot password?" on the login screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings → Security: a recovery-question picker (5 presets + Custom) + answer field + Save, posting to /api/profiles/1/set-recovery. handleRecoveryQuestionChange reveals the custom box. - Login screen: a "Forgot password?" link opens a recovery view — enter username → fetch your question → answer + new password → reset → reload signed in. Reuses the launch-PIN overlay styling/structure (entry + recovery views). All inert unless login mode is on, so a default/LAN install never sees any of it. 64 script-split integrity tests pass (every new handler resolves). --- webui/index.html | 54 ++++++++++++++++++++++---- webui/static/init.js | 91 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 7 deletions(-) diff --git a/webui/index.html b/webui/index.html index 4bbf1510..8da0f94a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -54,13 +54,33 @@ @@ -6040,6 +6060,26 @@ +
+ +
+ Lets you reset a forgotten login password by answering this. Recommended if you enable login. (You can set one per profile in Manage Profiles too.) +
+ + + + + +
+
- +
-

🔒 Security

- -