Profile Permissions & Page Access Control
This commit is contained in:
parent
a159ac3fd6
commit
c06fd044a1
5 changed files with 594 additions and 35 deletions
|
|
@ -340,6 +340,7 @@ class MusicDatabase:
|
|||
self._add_profile_support_v2(cursor)
|
||||
self._add_profile_support_v3(cursor)
|
||||
self._add_profile_support_v4(cursor)
|
||||
self._add_profile_settings(cursor)
|
||||
|
||||
# Mirrored playlists — persistent backup of parsed playlists from any service
|
||||
cursor.execute("""
|
||||
|
|
@ -2101,6 +2102,34 @@ class MusicDatabase:
|
|||
except Exception as e:
|
||||
logger.error(f"Error in profile support v4 migration: {e}")
|
||||
|
||||
def _add_profile_settings(self, cursor):
|
||||
"""Add home_page, allowed_pages, can_download columns to profiles table"""
|
||||
try:
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_migration_settings' LIMIT 1")
|
||||
if cursor.fetchone():
|
||||
return # Already migrated
|
||||
|
||||
logger.info("Applying profile settings migration...")
|
||||
|
||||
for col_sql in [
|
||||
"ALTER TABLE profiles ADD COLUMN home_page TEXT DEFAULT NULL",
|
||||
"ALTER TABLE profiles ADD COLUMN allowed_pages TEXT DEFAULT NULL",
|
||||
"ALTER TABLE profiles ADD COLUMN can_download INTEGER DEFAULT 1",
|
||||
]:
|
||||
try:
|
||||
cursor.execute(col_sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_migration_settings', '1')
|
||||
""")
|
||||
|
||||
logger.info("Profile settings migration completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in profile settings migration: {e}")
|
||||
|
||||
# ── Profile CRUD ──────────────────────────────────────────────────
|
||||
|
||||
def get_all_profiles(self) -> List[Dict[str, Any]]:
|
||||
|
|
@ -2114,16 +2143,23 @@ class MusicDatabase:
|
|||
cursor.execute("SELECT * FROM profiles ORDER BY id")
|
||||
rows = cursor.fetchall()
|
||||
columns = [desc[0] for desc in cursor.description]
|
||||
return [{
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'avatar_color': row['avatar_color'],
|
||||
'avatar_url': row['avatar_url'] if 'avatar_url' in columns else None,
|
||||
'is_admin': bool(row['is_admin']),
|
||||
'has_pin': row['pin_hash'] is not None,
|
||||
'created_at': row['created_at'],
|
||||
'updated_at': row['updated_at'],
|
||||
} for row in rows]
|
||||
results = []
|
||||
for row in rows:
|
||||
ap_raw = row['allowed_pages'] if 'allowed_pages' in columns else None
|
||||
results.append({
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'avatar_color': row['avatar_color'],
|
||||
'avatar_url': row['avatar_url'] if 'avatar_url' in columns else None,
|
||||
'is_admin': bool(row['is_admin']),
|
||||
'has_pin': row['pin_hash'] is not None,
|
||||
'home_page': row['home_page'] if 'home_page' in columns else None,
|
||||
'allowed_pages': json.loads(ap_raw) if ap_raw else None,
|
||||
'can_download': bool(row['can_download']) if 'can_download' in columns else True,
|
||||
'created_at': row['created_at'],
|
||||
'updated_at': row['updated_at'],
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting profiles: {e}")
|
||||
return [{'id': 1, 'name': 'Admin', 'avatar_color': '#6366f1', 'avatar_url': None, 'is_admin': True, 'has_pin': False}]
|
||||
|
|
@ -2137,6 +2173,7 @@ class MusicDatabase:
|
|||
row = cursor.fetchone()
|
||||
if row:
|
||||
columns = [desc[0] for desc in cursor.description]
|
||||
ap_raw = row['allowed_pages'] if 'allowed_pages' in columns else None
|
||||
return {
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
|
|
@ -2144,6 +2181,9 @@ class MusicDatabase:
|
|||
'avatar_url': row['avatar_url'] if 'avatar_url' in columns else None,
|
||||
'is_admin': bool(row['is_admin']),
|
||||
'has_pin': row['pin_hash'] is not None,
|
||||
'home_page': row['home_page'] if 'home_page' in columns else None,
|
||||
'allowed_pages': json.loads(ap_raw) if ap_raw else None,
|
||||
'can_download': bool(row['can_download']) if 'can_download' in columns else True,
|
||||
'created_at': row['created_at'],
|
||||
'updated_at': row['updated_at'],
|
||||
}
|
||||
|
|
@ -2154,15 +2194,17 @@ class MusicDatabase:
|
|||
|
||||
def create_profile(self, name: str, avatar_color: str = '#6366f1',
|
||||
pin_hash: Optional[str] = None, is_admin: bool = False,
|
||||
avatar_url: Optional[str] = None) -> Optional[int]:
|
||||
avatar_url: Optional[str] = None, home_page: Optional[str] = None,
|
||||
allowed_pages: Optional[list] = None, can_download: bool = True) -> Optional[int]:
|
||||
"""Create a new profile. Returns new profile ID or None on error."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
ap_json = json.dumps(allowed_pages) if allowed_pages is not None else None
|
||||
cursor.execute("""
|
||||
INSERT INTO profiles (name, avatar_color, pin_hash, is_admin, avatar_url)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (name, avatar_color, pin_hash, int(is_admin), avatar_url))
|
||||
INSERT INTO profiles (name, avatar_color, pin_hash, is_admin, avatar_url, home_page, allowed_pages, can_download)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (name, avatar_color, pin_hash, int(is_admin), avatar_url, home_page, ap_json, int(can_download)))
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
except sqlite3.IntegrityError:
|
||||
|
|
@ -2173,9 +2215,13 @@ class MusicDatabase:
|
|||
return None
|
||||
|
||||
def update_profile(self, profile_id: int, **kwargs) -> bool:
|
||||
"""Update profile fields. Accepts: name, avatar_color, avatar_url, pin_hash, is_admin."""
|
||||
allowed = {'name', 'avatar_color', 'avatar_url', 'pin_hash', 'is_admin'}
|
||||
"""Update profile fields. Accepts: name, avatar_color, avatar_url, pin_hash, is_admin, home_page, allowed_pages, can_download."""
|
||||
allowed = {'name', 'avatar_color', 'avatar_url', 'pin_hash', 'is_admin', 'home_page', 'allowed_pages', 'can_download'}
|
||||
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
||||
# Serialize allowed_pages list to JSON string for storage
|
||||
if 'allowed_pages' in updates:
|
||||
v = updates['allowed_pages']
|
||||
updates['allowed_pages'] = json.dumps(v) if v is not None else None
|
||||
if not updates:
|
||||
return False
|
||||
try:
|
||||
|
|
|
|||
100
web_server.py
100
web_server.py
|
|
@ -197,6 +197,22 @@ def get_current_profile_id() -> int:
|
|||
except AttributeError:
|
||||
return 1
|
||||
|
||||
# Valid page IDs for profile permission validation
|
||||
VALID_PAGE_IDS = {'dashboard', 'sync', 'downloads', 'discover', 'artists', 'automations', 'library', 'import', 'settings', 'help'}
|
||||
|
||||
def check_download_permission():
|
||||
"""Check if current profile has download permission. Returns error response or None if allowed."""
|
||||
pid = get_current_profile_id()
|
||||
if pid == 1:
|
||||
return None # Root admin always allowed
|
||||
try:
|
||||
profile = get_database().get_profile(pid)
|
||||
if profile and not profile.get('can_download', True):
|
||||
return jsonify({'success': False, 'error': 'Downloads are disabled for this profile.'}), 403
|
||||
except Exception:
|
||||
pass # DB error — don't block
|
||||
return None
|
||||
|
||||
# --- Docker Helper Functions ---
|
||||
def docker_resolve_path(path_str):
|
||||
"""
|
||||
|
|
@ -6465,6 +6481,9 @@ def stream_enhanced_search_track():
|
|||
@app.route('/api/download', methods=['POST'])
|
||||
def start_download():
|
||||
"""Simple download route"""
|
||||
dl_err = check_download_permission()
|
||||
if dl_err:
|
||||
return dl_err
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "No download data provided."}), 400
|
||||
|
|
@ -7096,6 +7115,9 @@ def get_task_candidates(task_id):
|
|||
@app.route('/api/downloads/task/<task_id>/download-candidate', methods=['POST'])
|
||||
def download_selected_candidate(task_id):
|
||||
"""Restart a not_found/failed task by downloading a user-selected candidate."""
|
||||
dl_err = check_download_permission()
|
||||
if dl_err:
|
||||
return dl_err
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or not data.get('username') or not data.get('filename'):
|
||||
|
|
@ -10806,6 +10828,9 @@ def start_matched_download():
|
|||
2. Regular album downloads (fallback)
|
||||
3. Single track downloads
|
||||
"""
|
||||
dl_err = check_download_permission()
|
||||
if dl_err:
|
||||
return dl_err
|
||||
try:
|
||||
data = request.get_json()
|
||||
download_payload = data.get('search_result', {})
|
||||
|
|
@ -14982,6 +15007,10 @@ def get_version_info():
|
|||
"title": "🔧 Recent Updates",
|
||||
"description": "Latest fixes and improvements",
|
||||
"features": [
|
||||
"• Profile Permissions — admin can control which pages each user can access and disable download functionality per profile",
|
||||
"• Per-user home page — every user can choose their own landing page; non-admin defaults to Discover",
|
||||
"• Enhanced Library Manager restricted to admin profiles only (management tool with inline editing)",
|
||||
"• Download buttons hidden globally for profiles with downloads disabled, enforced on both frontend and backend",
|
||||
"• Genius search fix — no longer blindly matches wrong artists (e.g. '50 Cent' → '108'); all bad matches auto-reset on first launch",
|
||||
"• Library page fix — albums no longer merge across different artists with the same album title/year",
|
||||
"• Post-processing race condition fix — no more MutagenError on files already moved by another thread",
|
||||
|
|
@ -15134,13 +15163,18 @@ def get_version_info():
|
|||
},
|
||||
{
|
||||
"title": "👥 Multi-Profile Support",
|
||||
"description": "Netflix-style profile picker for shared households",
|
||||
"description": "Netflix-style profile picker for shared households with per-profile permissions",
|
||||
"features": [
|
||||
"• Multiple profiles sharing one SoulSync instance with isolated personal data",
|
||||
"• Each profile gets its own watchlist, wishlist, discovery pool, and similar artists",
|
||||
"• Optional PIN protection per profile with admin-only management",
|
||||
"• Profile avatar images via URL with colored-initial fallback",
|
||||
"• Shared music library and service credentials across all profiles",
|
||||
"• Admin-controlled page access — choose which sidebar pages each profile can see",
|
||||
"• Per-profile download toggle — disable downloading for specific users (frontend + backend enforced)",
|
||||
"• Customizable home page — each user picks their own landing page from their permitted pages",
|
||||
"• Non-admin users default to Discover page instead of Dashboard",
|
||||
"• Self-service profile editing — non-admin users can change their name and home page",
|
||||
"• Zero-downtime migration — existing data maps to auto-created admin profile",
|
||||
"• Single-user installs see no changes until a second profile is created"
|
||||
]
|
||||
|
|
@ -16271,6 +16305,9 @@ def start_wishlist_missing_downloads():
|
|||
This endpoint fetches wishlist tracks and manages them with batch processing
|
||||
identical to playlist processing, maintaining exactly 3 concurrent downloads.
|
||||
"""
|
||||
dl_err = check_download_permission()
|
||||
if dl_err:
|
||||
return dl_err
|
||||
try:
|
||||
# Check if auto-processing is currently running (prevent concurrent wishlist access)
|
||||
if is_wishlist_actually_processing():
|
||||
|
|
@ -20092,6 +20129,9 @@ def start_playlist_missing_downloads(playlist_id):
|
|||
This endpoint receives the list of missing tracks and manages them with batch processing
|
||||
like the GUI, maintaining exactly 3 concurrent downloads.
|
||||
"""
|
||||
dl_err = check_download_permission()
|
||||
if dl_err:
|
||||
return dl_err
|
||||
data = request.get_json()
|
||||
missing_tracks = data.get('missing_tracks', [])
|
||||
if not missing_tracks:
|
||||
|
|
@ -21334,9 +21374,12 @@ def start_missing_tracks_process(playlist_id):
|
|||
@app.route('/api/tracks/download_missing', methods=['POST'])
|
||||
def start_missing_downloads():
|
||||
"""Legacy endpoint - redirect to new playlist-based endpoint"""
|
||||
dl_err = check_download_permission()
|
||||
if dl_err:
|
||||
return dl_err
|
||||
data = request.get_json()
|
||||
missing_tracks = data.get('missing_tracks', [])
|
||||
|
||||
|
||||
if not missing_tracks:
|
||||
return jsonify({"success": False, "error": "No missing tracks provided"}), 400
|
||||
|
||||
|
|
@ -25555,7 +25598,27 @@ def create_profile():
|
|||
from werkzeug.security import generate_password_hash
|
||||
pin_hash = generate_password_hash(pin, method='pbkdf2:sha256')
|
||||
|
||||
profile_id = database.create_profile(name, avatar_color, pin_hash, is_admin=False, avatar_url=avatar_url)
|
||||
# Profile settings: home_page, allowed_pages, can_download
|
||||
home_page = data.get('home_page') or None
|
||||
allowed_pages = data.get('allowed_pages') # list or None
|
||||
can_download = data.get('can_download', True)
|
||||
|
||||
# Validate page IDs
|
||||
if home_page and home_page not in VALID_PAGE_IDS:
|
||||
home_page = None
|
||||
if allowed_pages is not None:
|
||||
allowed_pages = [p for p in allowed_pages if p in VALID_PAGE_IDS]
|
||||
# Non-admin should never have 'settings' in allowed_pages
|
||||
if 'settings' in allowed_pages:
|
||||
allowed_pages.remove('settings')
|
||||
# If home_page not in allowed list, reset to first allowed or 'discover'
|
||||
if home_page and home_page not in allowed_pages:
|
||||
home_page = allowed_pages[0] if allowed_pages else None
|
||||
|
||||
profile_id = database.create_profile(
|
||||
name, avatar_color, pin_hash, is_admin=False, avatar_url=avatar_url,
|
||||
home_page=home_page, allowed_pages=allowed_pages, can_download=bool(can_download)
|
||||
)
|
||||
if profile_id is None:
|
||||
return jsonify({'success': False, 'error': 'Profile name already exists'}), 409
|
||||
|
||||
|
|
@ -25598,6 +25661,37 @@ def update_profile(profile_id):
|
|||
return jsonify({'success': False, 'error': 'Cannot remove the last admin'}), 400
|
||||
kwargs['is_admin'] = int(data['is_admin'])
|
||||
|
||||
# Home page — any user can change their own, admin can change anyone's
|
||||
if 'home_page' in data:
|
||||
hp = data['home_page'] or None
|
||||
if hp and hp not in VALID_PAGE_IDS:
|
||||
hp = None
|
||||
# Non-admin self-edit: validate home_page is in their allowed pages
|
||||
if not current['is_admin'] and current_pid == profile_id:
|
||||
target = database.get_profile(profile_id)
|
||||
ap = target.get('allowed_pages') if target else None
|
||||
if ap is not None and hp and hp not in ap:
|
||||
return jsonify({'success': False, 'error': 'Page not permitted'}), 400
|
||||
kwargs['home_page'] = hp
|
||||
|
||||
# Allowed pages & can_download — admin only
|
||||
if current['is_admin']:
|
||||
if 'allowed_pages' in data:
|
||||
ap = data['allowed_pages']
|
||||
if ap is not None:
|
||||
ap = [p for p in ap if p in VALID_PAGE_IDS]
|
||||
# Non-admin target should never have 'settings'
|
||||
target = database.get_profile(profile_id)
|
||||
if target and not target.get('is_admin'):
|
||||
ap = [p for p in ap if p != 'settings']
|
||||
# If current home_page not in new allowed list, reset it
|
||||
current_hp = kwargs.get('home_page') or (target.get('home_page') if target else None)
|
||||
if current_hp and current_hp not in ap:
|
||||
kwargs['home_page'] = ap[0] if ap else None
|
||||
kwargs['allowed_pages'] = ap
|
||||
if 'can_download' in data:
|
||||
kwargs['can_download'] = int(bool(data['can_download']))
|
||||
|
||||
success = database.update_profile(profile_id, **kwargs)
|
||||
return jsonify({'success': success})
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -56,6 +56,35 @@
|
|||
<span class="profile-color-swatch" data-color="#14b8a6" style="background:#14b8a6" title="Teal"></span>
|
||||
</div>
|
||||
<input type="password" id="new-profile-pin" class="profile-input" placeholder="PIN (optional)" maxlength="6">
|
||||
<div class="profile-settings-section">
|
||||
<label class="profile-settings-label">Home Page</label>
|
||||
<select id="new-profile-home-page" class="profile-input">
|
||||
<option value="">Default (Discover)</option>
|
||||
<option value="dashboard">Dashboard</option>
|
||||
<option value="sync">Sync</option>
|
||||
<option value="downloads">Search</option>
|
||||
<option value="discover">Discover</option>
|
||||
<option value="artists">Artists</option>
|
||||
<option value="automations">Automations</option>
|
||||
<option value="library">Library</option>
|
||||
<option value="import">Import</option>
|
||||
</select>
|
||||
<label class="profile-settings-label">Page Access</label>
|
||||
<div id="new-profile-allowed-pages" class="profile-page-checkboxes">
|
||||
<label><input type="checkbox" value="dashboard" checked> Dashboard</label>
|
||||
<label><input type="checkbox" value="sync" checked> Sync</label>
|
||||
<label><input type="checkbox" value="downloads" checked> Search</label>
|
||||
<label><input type="checkbox" value="discover" checked> Discover</label>
|
||||
<label><input type="checkbox" value="artists" checked> Artists</label>
|
||||
<label><input type="checkbox" value="automations" checked> Automations</label>
|
||||
<label><input type="checkbox" value="library" checked> Library</label>
|
||||
<label><input type="checkbox" value="import" checked> Import</label>
|
||||
<label><input type="checkbox" value="help" checked disabled> Help & Docs</label>
|
||||
</div>
|
||||
<label class="profile-checkbox-label">
|
||||
<input type="checkbox" id="new-profile-can-download" checked> Can download music
|
||||
</label>
|
||||
</div>
|
||||
<button id="create-profile-btn" class="profile-create-btn">Create Profile</button>
|
||||
</div>
|
||||
<div id="admin-pin-section" class="admin-pin-section" style="display: none;">
|
||||
|
|
|
|||
|
|
@ -717,6 +717,34 @@ function initAccentColorListeners() {
|
|||
// ── Profile System ─────────────────────────────────────────────
|
||||
let currentProfile = null;
|
||||
|
||||
function getProfileHomePage() {
|
||||
if (!currentProfile) return 'dashboard';
|
||||
if (currentProfile.home_page) return currentProfile.home_page;
|
||||
return currentProfile.is_admin ? 'dashboard' : 'discover';
|
||||
}
|
||||
|
||||
function isPageAllowed(pageId) {
|
||||
if (!currentProfile) return true;
|
||||
if (currentProfile.id === 1) return true;
|
||||
if (pageId === 'help') return true;
|
||||
if (pageId === 'artist-detail') {
|
||||
// artist-detail requires library access
|
||||
const ap = currentProfile.allowed_pages;
|
||||
if (!ap) return true;
|
||||
return ap.includes('library');
|
||||
}
|
||||
if (pageId === 'settings') return currentProfile.is_admin;
|
||||
const ap = currentProfile.allowed_pages;
|
||||
if (!ap) return true; // null = all pages
|
||||
return ap.includes(pageId);
|
||||
}
|
||||
|
||||
function canDownload() {
|
||||
if (!currentProfile) return true;
|
||||
if (currentProfile.id === 1) return true;
|
||||
return currentProfile.can_download !== false && currentProfile.can_download !== 0;
|
||||
}
|
||||
|
||||
function renderProfileAvatar(el, profile) {
|
||||
// Renders avatar as image (if avatar_url set) or colored initial fallback
|
||||
// Preserves existing classes, ensures 'profile-avatar' is present
|
||||
|
|
@ -803,10 +831,26 @@ function showProfilePicker(profiles, canCancel = false) {
|
|||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
// Show manage button only if current profile is admin (not on first load before selection)
|
||||
// Show actions: admin sees "Manage Profiles", non-admin sees "My Profile" (when they have a profile selected)
|
||||
const isAdmin = currentProfile ? currentProfile.is_admin : false;
|
||||
const manageBtn = document.getElementById('manage-profiles-btn');
|
||||
if (isAdmin) {
|
||||
actions.style.display = '';
|
||||
if (manageBtn) {
|
||||
manageBtn.textContent = 'Manage Profiles';
|
||||
// Reset onclick to admin handler (initProfileManagement sets this, but re-affirm here)
|
||||
manageBtn.onclick = () => {
|
||||
document.getElementById('profile-manage-panel').style.display = 'flex';
|
||||
loadProfileManageList();
|
||||
};
|
||||
}
|
||||
} else if (currentProfile && canCancel) {
|
||||
// Non-admin with an active profile: show "My Profile" to edit own settings
|
||||
actions.style.display = '';
|
||||
if (manageBtn) {
|
||||
manageBtn.textContent = 'My Profile';
|
||||
manageBtn.onclick = () => showSelfEditForm();
|
||||
}
|
||||
} else {
|
||||
actions.style.display = 'none';
|
||||
}
|
||||
|
|
@ -971,12 +1015,28 @@ function updateProfileIndicator() {
|
|||
}
|
||||
};
|
||||
|
||||
// Hide settings nav for non-admin
|
||||
const settingsBtn = document.querySelector('.nav-button[data-page="settings"]');
|
||||
if (settingsBtn && !currentProfile.is_admin) {
|
||||
settingsBtn.style.display = 'none';
|
||||
} else if (settingsBtn) {
|
||||
settingsBtn.style.display = '';
|
||||
// Filter sidebar pages based on profile permissions
|
||||
document.querySelectorAll('.nav-button[data-page]').forEach(btn => {
|
||||
const page = btn.getAttribute('data-page');
|
||||
if (page === 'hydrabase') return; // Managed by dev mode toggle
|
||||
if (page === 'settings') {
|
||||
// Settings always gated by is_admin
|
||||
btn.style.display = currentProfile.is_admin ? '' : 'none';
|
||||
} else if (page === 'help') {
|
||||
btn.style.display = ''; // Always visible
|
||||
} else if (currentProfile.id === 1) {
|
||||
btn.style.display = ''; // Root admin sees all
|
||||
} else {
|
||||
const ap = currentProfile.allowed_pages;
|
||||
btn.style.display = (!ap || ap.includes(page)) ? '' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle download capability
|
||||
if (canDownload()) {
|
||||
document.body.classList.remove('downloads-disabled');
|
||||
} else {
|
||||
document.body.classList.add('downloads-disabled');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1024,16 +1084,33 @@ function initProfileManagement() {
|
|||
const pin = document.getElementById('new-profile-pin').value;
|
||||
if (!name) return;
|
||||
|
||||
// Collect profile settings
|
||||
const homePage = document.getElementById('new-profile-home-page').value || null;
|
||||
const pageCheckboxes = document.querySelectorAll('#new-profile-allowed-pages input[type="checkbox"]:not(:disabled)');
|
||||
const allChecked = Array.from(pageCheckboxes).every(cb => cb.checked);
|
||||
const allowedPages = allChecked ? null : Array.from(pageCheckboxes).filter(cb => cb.checked).map(cb => cb.value);
|
||||
const canDl = document.getElementById('new-profile-can-download').checked;
|
||||
|
||||
const res = await fetch('/api/profiles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, avatar_color: selectedColor, avatar_url: avatarUrl || undefined, pin: pin || undefined })
|
||||
body: JSON.stringify({
|
||||
name, avatar_color: selectedColor,
|
||||
avatar_url: avatarUrl || undefined,
|
||||
pin: pin || undefined,
|
||||
home_page: homePage,
|
||||
allowed_pages: allowedPages,
|
||||
can_download: canDl
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('new-profile-name').value = '';
|
||||
document.getElementById('new-profile-avatar-url').value = '';
|
||||
document.getElementById('new-profile-pin').value = '';
|
||||
document.getElementById('new-profile-home-page').value = '';
|
||||
pageCheckboxes.forEach(cb => cb.checked = true);
|
||||
document.getElementById('new-profile-can-download').checked = true;
|
||||
loadProfileManageList();
|
||||
// Show admin PIN section if >1 profiles and admin has no PIN
|
||||
checkAdminPinRequired();
|
||||
|
|
@ -1096,10 +1173,14 @@ async function loadProfileManageList() {
|
|||
nameDiv.className = 'name';
|
||||
nameDiv.textContent = p.name + (p.has_pin ? ' 🔒' : '');
|
||||
info.appendChild(nameDiv);
|
||||
if (p.is_admin) {
|
||||
const roleTags = [];
|
||||
if (p.is_admin) roleTags.push('Admin');
|
||||
if (p.can_download === false) roleTags.push('No Downloads');
|
||||
if (p.allowed_pages) roleTags.push(`${p.allowed_pages.length} pages`);
|
||||
if (roleTags.length) {
|
||||
const roleDiv = document.createElement('div');
|
||||
roleDiv.className = 'role';
|
||||
roleDiv.textContent = 'Admin';
|
||||
roleDiv.textContent = roleTags.join(' · ');
|
||||
info.appendChild(roleDiv);
|
||||
}
|
||||
item.appendChild(info);
|
||||
|
|
@ -1113,6 +1194,10 @@ async function loadProfileManageList() {
|
|||
editBtn.dataset.name = p.name;
|
||||
editBtn.dataset.color = p.avatar_color || '#6366f1';
|
||||
editBtn.dataset.avatarUrl = p.avatar_url || '';
|
||||
editBtn.dataset.homePage = p.home_page || '';
|
||||
editBtn.dataset.allowedPages = p.allowed_pages ? JSON.stringify(p.allowed_pages) : '';
|
||||
editBtn.dataset.canDownload = p.can_download !== false ? '1' : '0';
|
||||
editBtn.dataset.isAdmin = p.is_admin ? '1' : '0';
|
||||
editBtn.title = 'Edit profile';
|
||||
editBtn.textContent = '✏️';
|
||||
actions.appendChild(editBtn);
|
||||
|
|
@ -1133,7 +1218,12 @@ async function loadProfileManageList() {
|
|||
// Bind edit buttons
|
||||
list.querySelectorAll('.profile-edit-btn').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
showProfileEditForm(btn.dataset.id, btn.dataset.name, btn.dataset.color, btn.dataset.avatarUrl);
|
||||
showProfileEditForm(btn.dataset.id, btn.dataset.name, btn.dataset.color, btn.dataset.avatarUrl, {
|
||||
home_page: btn.dataset.homePage || '',
|
||||
allowed_pages: btn.dataset.allowedPages ? JSON.parse(btn.dataset.allowedPages) : null,
|
||||
can_download: btn.dataset.canDownload !== '0',
|
||||
is_admin: btn.dataset.isAdmin === '1'
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -1157,13 +1247,19 @@ async function loadProfileManageList() {
|
|||
checkAdminPinRequired();
|
||||
}
|
||||
|
||||
function showProfileEditForm(profileId, currentName, currentColor, currentAvatarUrl) {
|
||||
function showProfileEditForm(profileId, currentName, currentColor, currentAvatarUrl, profileSettings = {}) {
|
||||
const list = document.getElementById('profile-manage-list');
|
||||
// Remove any existing edit form
|
||||
const existing = document.getElementById('profile-edit-form');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const isAdmin = currentProfile && currentProfile.is_admin;
|
||||
const isEditingAdmin = profileSettings.is_admin;
|
||||
const editColors = ['#6366f1','#ec4899','#10b981','#f59e0b','#3b82f6','#ef4444','#8b5cf6','#14b8a6'];
|
||||
const pageLabels = {
|
||||
dashboard: 'Dashboard', sync: 'Sync', downloads: 'Search', discover: 'Discover',
|
||||
artists: 'Artists', automations: 'Automations', library: 'Library', import: 'Import'
|
||||
};
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.id = 'profile-edit-form';
|
||||
|
|
@ -1201,6 +1297,73 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
|
|||
});
|
||||
form.appendChild(colorRow);
|
||||
|
||||
// Home page selector — visible to everyone (self-edit or admin editing others)
|
||||
const homeLabel = document.createElement('label');
|
||||
homeLabel.className = 'profile-settings-label';
|
||||
homeLabel.textContent = 'Home Page';
|
||||
form.appendChild(homeLabel);
|
||||
|
||||
const homeSelect = document.createElement('select');
|
||||
homeSelect.className = 'profile-input';
|
||||
const defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = isEditingAdmin ? 'Default (Dashboard)' : 'Default (Discover)';
|
||||
homeSelect.appendChild(defaultOpt);
|
||||
// Filter home page options to only allowed pages
|
||||
const allowedSet = profileSettings.allowed_pages;
|
||||
Object.entries(pageLabels).forEach(([id, label]) => {
|
||||
if (allowedSet && !allowedSet.includes(id)) return; // Skip non-permitted
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = label;
|
||||
if (id === profileSettings.home_page) opt.selected = true;
|
||||
homeSelect.appendChild(opt);
|
||||
});
|
||||
form.appendChild(homeSelect);
|
||||
|
||||
// Admin-only settings: allowed pages & can_download
|
||||
let pageCheckboxes = [];
|
||||
let canDlCheckbox = null;
|
||||
if (isAdmin && !isEditingAdmin) {
|
||||
const apLabel = document.createElement('label');
|
||||
apLabel.className = 'profile-settings-label';
|
||||
apLabel.textContent = 'Page Access';
|
||||
form.appendChild(apLabel);
|
||||
|
||||
const apContainer = document.createElement('div');
|
||||
apContainer.className = 'profile-page-checkboxes';
|
||||
Object.entries(pageLabels).forEach(([id, label]) => {
|
||||
const lbl = document.createElement('label');
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.value = id;
|
||||
cb.checked = !allowedSet || allowedSet.includes(id);
|
||||
lbl.appendChild(cb);
|
||||
lbl.appendChild(document.createTextNode(' ' + label));
|
||||
apContainer.appendChild(lbl);
|
||||
pageCheckboxes.push(cb);
|
||||
});
|
||||
// Always-on help
|
||||
const helpLbl = document.createElement('label');
|
||||
const helpCb = document.createElement('input');
|
||||
helpCb.type = 'checkbox';
|
||||
helpCb.checked = true;
|
||||
helpCb.disabled = true;
|
||||
helpLbl.appendChild(helpCb);
|
||||
helpLbl.appendChild(document.createTextNode(' Help & Docs'));
|
||||
apContainer.appendChild(helpLbl);
|
||||
form.appendChild(apContainer);
|
||||
|
||||
const dlLabel = document.createElement('label');
|
||||
dlLabel.className = 'profile-checkbox-label';
|
||||
canDlCheckbox = document.createElement('input');
|
||||
canDlCheckbox.type = 'checkbox';
|
||||
canDlCheckbox.checked = profileSettings.can_download !== false;
|
||||
dlLabel.appendChild(canDlCheckbox);
|
||||
dlLabel.appendChild(document.createTextNode(' Can download music'));
|
||||
form.appendChild(dlLabel);
|
||||
}
|
||||
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.className = 'profile-edit-buttons';
|
||||
|
||||
|
|
@ -1211,11 +1374,23 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
|
|||
const newName = nameInput.value.trim();
|
||||
if (!newName) { alert('Name cannot be empty'); return; }
|
||||
const newAvatarUrl = urlInput.value.trim() || null;
|
||||
const payload = { name: newName, avatar_color: editColor, avatar_url: newAvatarUrl };
|
||||
|
||||
// Home page
|
||||
payload.home_page = homeSelect.value || null;
|
||||
|
||||
// Admin-only fields
|
||||
if (isAdmin && !isEditingAdmin && pageCheckboxes.length) {
|
||||
const allChecked = pageCheckboxes.every(cb => cb.checked);
|
||||
payload.allowed_pages = allChecked ? null : pageCheckboxes.filter(cb => cb.checked).map(cb => cb.value);
|
||||
payload.can_download = canDlCheckbox ? canDlCheckbox.checked : true;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/profiles/${profileId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName, avatar_color: editColor, avatar_url: newAvatarUrl })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
|
|
@ -1224,6 +1399,9 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
|
|||
currentProfile.name = newName;
|
||||
currentProfile.avatar_color = editColor;
|
||||
currentProfile.avatar_url = newAvatarUrl;
|
||||
if (payload.home_page !== undefined) currentProfile.home_page = payload.home_page;
|
||||
if (payload.allowed_pages !== undefined) currentProfile.allowed_pages = payload.allowed_pages;
|
||||
if (payload.can_download !== undefined) currentProfile.can_download = payload.can_download;
|
||||
updateProfileIndicator();
|
||||
}
|
||||
loadProfileManageList();
|
||||
|
|
@ -1248,6 +1426,117 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
|
|||
nameInput.select();
|
||||
}
|
||||
|
||||
function showSelfEditForm() {
|
||||
if (!currentProfile) return;
|
||||
const overlay = document.getElementById('profile-picker-overlay');
|
||||
const container = overlay.querySelector('.profile-picker-container');
|
||||
|
||||
// Hide the picker grid and show self-edit form
|
||||
const grid = document.getElementById('profile-picker-grid');
|
||||
const actions = document.getElementById('profile-picker-actions');
|
||||
grid.style.display = 'none';
|
||||
actions.style.display = 'none';
|
||||
|
||||
// Remove any existing self-edit form
|
||||
const existing = document.getElementById('self-edit-form');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const pageLabels = {
|
||||
dashboard: 'Dashboard', sync: 'Sync', downloads: 'Search', discover: 'Discover',
|
||||
artists: 'Artists', automations: 'Automations', library: 'Library', import: 'Import'
|
||||
};
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.id = 'self-edit-form';
|
||||
form.className = 'profile-edit-form';
|
||||
form.style.marginTop = '16px';
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = 'My Profile';
|
||||
title.style.cssText = 'color: #fff; margin: 0 0 12px; font-size: 18px;';
|
||||
form.appendChild(title);
|
||||
|
||||
// Name
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.className = 'profile-input';
|
||||
nameInput.value = currentProfile.name;
|
||||
nameInput.maxLength = 20;
|
||||
nameInput.placeholder = 'Profile name';
|
||||
form.appendChild(nameInput);
|
||||
|
||||
// Home page
|
||||
const homeLabel = document.createElement('label');
|
||||
homeLabel.className = 'profile-settings-label';
|
||||
homeLabel.textContent = 'Home Page';
|
||||
form.appendChild(homeLabel);
|
||||
|
||||
const homeSelect = document.createElement('select');
|
||||
homeSelect.className = 'profile-input';
|
||||
const defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = 'Default (Discover)';
|
||||
homeSelect.appendChild(defaultOpt);
|
||||
const ap = currentProfile.allowed_pages;
|
||||
Object.entries(pageLabels).forEach(([id, label]) => {
|
||||
if (ap && !ap.includes(id)) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = label;
|
||||
if (id === currentProfile.home_page) opt.selected = true;
|
||||
homeSelect.appendChild(opt);
|
||||
});
|
||||
form.appendChild(homeSelect);
|
||||
|
||||
// Buttons
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.className = 'profile-edit-buttons';
|
||||
btnRow.style.marginTop = '12px';
|
||||
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'profile-create-btn';
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.onclick = async () => {
|
||||
const newName = nameInput.value.trim();
|
||||
if (!newName) { alert('Name cannot be empty'); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/profiles/${currentProfile.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName, home_page: homeSelect.value || null })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
currentProfile.name = newName;
|
||||
currentProfile.home_page = homeSelect.value || null;
|
||||
updateProfileIndicator();
|
||||
closeSelfEdit();
|
||||
hideProfilePicker();
|
||||
} else {
|
||||
alert(data.error || 'Failed to update');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Connection error');
|
||||
}
|
||||
};
|
||||
btnRow.appendChild(saveBtn);
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'profile-picker-cancel';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.onclick = () => closeSelfEdit();
|
||||
btnRow.appendChild(cancelBtn);
|
||||
|
||||
form.appendChild(btnRow);
|
||||
container.appendChild(form);
|
||||
|
||||
function closeSelfEdit() {
|
||||
form.remove();
|
||||
grid.style.display = '';
|
||||
actions.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAdminPinRequired() {
|
||||
const res = await fetch('/api/profiles');
|
||||
const data = await res.json();
|
||||
|
|
@ -1457,6 +1746,15 @@ function initializeDownloadManagerToggle() {
|
|||
function navigateToPage(pageId) {
|
||||
if (pageId === currentPage) return;
|
||||
|
||||
// Permission guard — redirect to home page if not allowed
|
||||
if (!isPageAllowed(pageId)) {
|
||||
const home = getProfileHomePage();
|
||||
if (home !== currentPage && isPageAllowed(home)) {
|
||||
navigateToPage(home);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update navigation buttons (only if there's a nav button for this page)
|
||||
document.querySelectorAll('.nav-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
|
|
@ -6724,8 +7022,13 @@ async function loadInitialData() {
|
|||
// Load discover download state
|
||||
await hydrateDiscoverDownloadsFromSnapshot();
|
||||
|
||||
// Load dashboard data by default
|
||||
await loadDashboardData();
|
||||
// Navigate to user's home page (or dashboard for admin)
|
||||
const homePage = getProfileHomePage();
|
||||
if (homePage !== 'dashboard') {
|
||||
navigateToPage(homePage);
|
||||
} else {
|
||||
await loadDashboardData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading initial data:', error);
|
||||
}
|
||||
|
|
@ -32465,11 +32768,15 @@ function navigateToArtistDetail(artistId, artistName) {
|
|||
artistDetailPageState.enhancedTrackSort = {};
|
||||
artistDetailPageState.enhancedView = false;
|
||||
|
||||
// Reset enhanced view toggle to standard
|
||||
// Reset enhanced view toggle to standard (hide for non-admin)
|
||||
const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn');
|
||||
const isAdminUser = currentProfile && currentProfile.is_admin;
|
||||
toggleBtns.forEach(btn => {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-view') === 'standard');
|
||||
});
|
||||
// Hide the View toggle filter group entirely for non-admin
|
||||
const viewFilterGroup = toggleBtns[0] && toggleBtns[0].closest('.filter-group');
|
||||
if (viewFilterGroup) viewFilterGroup.style.display = isAdminUser ? '' : 'none';
|
||||
const enhancedContainer = document.getElementById('enhanced-view-container');
|
||||
if (enhancedContainer) enhancedContainer.classList.add('hidden');
|
||||
const standardSections = document.querySelector('.discography-sections');
|
||||
|
|
@ -33593,6 +33900,9 @@ function applyDiscographyFilters() {
|
|||
// ==================== Enhanced Library Management View ====================
|
||||
|
||||
function toggleEnhancedView(enabled) {
|
||||
// Enhanced view is admin-only (management tool with inline editing)
|
||||
if (enabled && currentProfile && !currentProfile.is_admin) return;
|
||||
|
||||
const standardSections = document.querySelector('.discography-sections');
|
||||
const enhancedContainer = document.getElementById('enhanced-view-container');
|
||||
const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn');
|
||||
|
|
|
|||
|
|
@ -30238,6 +30238,11 @@ body {
|
|||
outline: none;
|
||||
}
|
||||
|
||||
.profile-pin-input::placeholder {
|
||||
font-size: 13px;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.profile-pin-input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
|
@ -30417,6 +30422,7 @@ body {
|
|||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #e5e7eb;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
|
|
@ -30428,6 +30434,10 @@ body {
|
|||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.profile-input::placeholder {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.profile-color-picker {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
@ -30481,6 +30491,76 @@ body {
|
|||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
/* Profile Settings in Management Panel */
|
||||
.profile-settings-section {
|
||||
margin: 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-settings-label {
|
||||
color: #d1d5db;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.profile-page-checkboxes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 12px;
|
||||
}
|
||||
|
||||
.profile-page-checkboxes label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #d1d5db;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.profile-page-checkboxes label input[type="checkbox"] {
|
||||
accent-color: rgba(var(--accent-rgb), 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #d1d5db;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.profile-checkbox-label input[type="checkbox"] {
|
||||
accent-color: rgba(var(--accent-rgb), 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Download-disabled state — hide download buttons globally */
|
||||
body.downloads-disabled .download-result-btn,
|
||||
body.downloads-disabled .download-btn,
|
||||
body.downloads-disabled .download-button,
|
||||
body.downloads-disabled .album-download-btn,
|
||||
body.downloads-disabled .download-missing-btn,
|
||||
body.downloads-disabled .enh-download-btn,
|
||||
body.downloads-disabled .enh-download-all-btn,
|
||||
body.downloads-disabled [onclick*="downloadTrack"],
|
||||
body.downloads-disabled [onclick*="downloadAlbum"],
|
||||
body.downloads-disabled [onclick*="startDownload"],
|
||||
body.downloads-disabled [onclick*="startMissingTracksProcess"],
|
||||
body.downloads-disabled [onclick*="startWishlistMissingTracksProcess"],
|
||||
body.downloads-disabled [onclick*="DownloadMissing"]:not([onclick*="close"]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Profile Indicator in Sidebar */
|
||||
.profile-indicator {
|
||||
display: flex;
|
||||
|
|
|
|||
Loading…
Reference in a new issue