Profile Permissions & Page Access Control

This commit is contained in:
Broque Thomas 2026-03-10 23:23:43 -07:00
parent a159ac3fd6
commit c06fd044a1
5 changed files with 594 additions and 35 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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;">

View file

@ -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');

View file

@ -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;