diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..21586c4 --- /dev/null +++ b/doc/README.md @@ -0,0 +1,7 @@ +# Technical Documentation + +This directory contains developer-facing technical documentation for Sonobarr. + +## Index + +- [User management and approval flow](./user-management.md) diff --git a/doc/user-management.md b/doc/user-management.md new file mode 100644 index 0000000..d5c2118 --- /dev/null +++ b/doc/user-management.md @@ -0,0 +1,36 @@ +# User Management And Artist Approval + +## Scope + +This document describes the user access flags that control account status, administrative privileges, and artist approval behavior. + +## User Flags + +The `users` table includes these access-control fields: + +- `is_active`: Controls whether the user can authenticate. +- `is_admin`: Grants access to admin routes and direct artist addition. +- `auto_approve_artist_requests`: Allows non-admin users to add artists directly without waiting for manual admin approval. + +## Admin UI Controls + +The user management page at `/admin/users` provides toggles for: + +- Active account +- Admin privileges +- Auto approve artist additions + +Admins can set these flags when creating a user and when editing an existing user. + +## Artist Addition And Request Flow + +Server-side behavior is enforced in `DataHandler`: + +- Users with `is_admin = true` or `auto_approve_artist_requests = true` can add artists directly to Lidarr. +- Users without either flag submit a pending artist request for manual admin approval. +- Unauthorized direct add attempts are rejected with an "Approval Required" message. + +The frontend receives permission metadata from the socket `user_info` payload and renders the action button accordingly: + +- Direct-add users see the default add action. +- Manual-approval users see the request action. diff --git a/migrations/versions/20260303_01_add_auto_approve_artist_requests_to_users.py b/migrations/versions/20260303_01_add_auto_approve_artist_requests_to_users.py new file mode 100644 index 0000000..a927d5d --- /dev/null +++ b/migrations/versions/20260303_01_add_auto_approve_artist_requests_to_users.py @@ -0,0 +1,44 @@ +"""add auto approve artist requests flag to users + +Revision ID: 20260303_01 +Revises: 20251222_01 +Create Date: 2026-03-03 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision = "20260303_01" +down_revision = "20251222_01" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + existing_columns = {column["name"] for column in inspector.get_columns("users")} + + with op.batch_alter_table("users", schema=None) as batch_op: + if "auto_approve_artist_requests" not in existing_columns: + batch_op.add_column( + sa.Column( + "auto_approve_artist_requests", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ) + ) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + existing_columns = {column["name"] for column in inspector.get_columns("users")} + + with op.batch_alter_table("users", schema=None) as batch_op: + if "auto_approve_artist_requests" in existing_columns: + batch_op.drop_column("auto_approve_artist_requests") diff --git a/src/sonobarr_app/models.py b/src/sonobarr_app/models.py index df51e39..7294da2 100644 --- a/src/sonobarr_app/models.py +++ b/src/sonobarr_app/models.py @@ -9,6 +9,8 @@ from .extensions import db class User(UserMixin, db.Model): + """Application user account with authentication and role flags.""" + __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) @@ -21,6 +23,7 @@ class User(UserMixin, db.Model): listenbrainz_username = db.Column(db.String(120), nullable=True) is_admin = db.Column(db.Boolean, default=False, nullable=False) is_active = db.Column(db.Boolean, default=True, nullable=False) + auto_approve_artist_requests = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column( db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False @@ -39,7 +42,10 @@ class User(UserMixin, db.Model): return self.display_name or self.username def __repr__(self) -> str: # pragma: no cover - representation helper - return f"" + return ( + f"" + ) class ArtistRequest(db.Model): diff --git a/src/sonobarr_app/services/data_handler.py b/src/sonobarr_app/services/data_handler.py index e2ecb99..39deb2e 100644 --- a/src/sonobarr_app/services/data_handler.py +++ b/src/sonobarr_app/services/data_handler.py @@ -50,9 +50,12 @@ LIDARR_MONITOR_NEW_ITEM_TYPES = { @dataclass class SessionState: + """Per-socket user state used for discovery and authorization decisions.""" + sid: str user_id: Optional[int] is_admin: bool = False + auto_approve_artist_requests: bool = False recommended_artists: List[dict] = field(default_factory=list) lidarr_items: List[dict] = field(default_factory=list) cleaned_lidarr_items: List[str] = field(default_factory=list) @@ -275,15 +278,29 @@ class DataHandler: setattr(self, attr, coerced_bool) # Session helpers ------------------------------------------------- - def ensure_session(self, sid: str, user_id: Optional[int] = None, is_admin: bool = False) -> SessionState: + def ensure_session( + self, + sid: str, + user_id: Optional[int] = None, + is_admin: bool = False, + auto_approve_artist_requests: bool = False, + ) -> SessionState: + """Return existing socket session or create one with current user flags.""" + with self.sessions_lock: session = self.sessions.get(sid) if session is None: - session = SessionState(sid=sid, user_id=user_id, is_admin=is_admin) + session = SessionState( + sid=sid, + user_id=user_id, + is_admin=is_admin, + auto_approve_artist_requests=auto_approve_artist_requests, + ) self.sessions[sid] = session elif user_id is not None: session.user_id = user_id session.is_admin = is_admin + session.auto_approve_artist_requests = auto_approve_artist_requests return session def get_session_if_exists(self, sid: str) -> Optional[SessionState]: @@ -318,6 +335,23 @@ class DataHandler: except (TypeError, ValueError): return None + def _sync_session_permissions(self, session: SessionState) -> None: + """Refresh role flags from the database for current authorization checks.""" + + user = self._resolve_user(session.user_id) + if user is None: + session.is_admin = False + session.auto_approve_artist_requests = False + return + session.is_admin = bool(user.is_admin) + session.auto_approve_artist_requests = bool(user.auto_approve_artist_requests) + + def _can_add_without_approval(self, session: SessionState) -> bool: + """Return whether the session user can add artists directly to Lidarr.""" + + self._sync_session_permissions(session) + return bool(session.is_admin or session.auto_approve_artist_requests) + def emit_personal_sources_state(self, sid: str) -> None: session = self.get_session_if_exists(sid) if session is None: @@ -400,10 +434,29 @@ class DataHandler: return deduped # Socket helpers -------------------------------------------------- - def connection(self, sid: str, user_id: Optional[int], is_admin: bool = False) -> None: - session = self.ensure_session(sid, user_id, is_admin) + def connection( + self, + sid: str, + user_id: Optional[int], + is_admin: bool = False, + auto_approve_artist_requests: bool = False, + ) -> None: + """Initialize socket session state and emit current user capability flags.""" + + session = self.ensure_session(sid, user_id, is_admin, auto_approve_artist_requests) + self._sync_session_permissions(session) # Send user info to frontend - self.socketio.emit("user_info", {"is_admin": session.is_admin}, room=sid) + self.socketio.emit( + "user_info", + { + "is_admin": session.is_admin, + "auto_approve_artist_requests": session.auto_approve_artist_requests, + "can_add_without_approval": bool( + session.is_admin or session.auto_approve_artist_requests + ), + }, + room=sid, + ) if session.recommended_artists: self.socketio.emit("more_artists_loaded", session.recommended_artists, room=sid) if session.lidarr_items: @@ -1028,11 +1081,44 @@ class DataHandler: # Lidarr artist creation ------------------------------------------ def add_artists(self, sid: str, raw_artist_name: str) -> str: + """Add an artist to Lidarr when the connected user is authorized to bypass approval.""" + session = self.ensure_session(sid) artist_name = urllib.parse.unquote(raw_artist_name) artist_folder = artist_name.replace("/", " ") status = "Failed to Add" + if not session.user_id: + self.socketio.emit( + "new_toast_msg", + { + "title": "Authentication Error", + "message": "You must be logged in to add artists.", + }, + room=sid, + ) + for item in session.recommended_artists: + if item["Name"] == artist_name: + item["Status"] = status + self.socketio.emit("refresh_artist", item, room=sid) + break + return status + if not self._can_add_without_approval(session): + self.socketio.emit( + "new_toast_msg", + { + "title": "Approval Required", + "message": "You do not have permission to add artists directly.", + }, + room=sid, + ) + for item in session.recommended_artists: + if item["Name"] == artist_name: + item["Status"] = status + self.socketio.emit("refresh_artist", item, room=sid) + break + return status + try: musicbrainzngs.set_useragent(self.app_name, self.app_rev, self.app_url) mbid = self.get_mbid_from_musicbrainz(artist_name) @@ -1162,6 +1248,8 @@ class DataHandler: return status def request_artist(self, sid: str, raw_artist_name: str) -> None: + """Create a pending request unless the user can auto-approve and add directly.""" + session = self.ensure_session(sid) if not session.user_id: self.socketio.emit( @@ -1173,7 +1261,11 @@ class DataHandler: room=sid, ) return - + + if self._can_add_without_approval(session): + self.add_artists(sid, raw_artist_name) + return + artist_name = urllib.parse.unquote(raw_artist_name) try: diff --git a/src/sonobarr_app/sockets/__init__.py b/src/sonobarr_app/sockets/__init__.py index 408eb68..c58bd88 100644 --- a/src/sonobarr_app/sockets/__init__.py +++ b/src/sonobarr_app/sockets/__init__.py @@ -19,7 +19,12 @@ def register_socketio_handlers(socketio: SocketIO, data_handler) -> None: user_id = int(identifier) if identifier is not None else None except (TypeError, ValueError): user_id = None - data_handler.connection(sid, user_id, current_user.is_admin) + data_handler.connection( + sid, + user_id, + current_user.is_admin, + getattr(current_user, "auto_approve_artist_requests", False), + ) @socketio.on("disconnect") def handle_disconnect(): diff --git a/src/sonobarr_app/web/admin.py b/src/sonobarr_app/web/admin.py index ffd45fa..da81a32 100644 --- a/src/sonobarr_app/web/admin.py +++ b/src/sonobarr_app/web/admin.py @@ -25,12 +25,15 @@ def admin_required(view): def _create_user_from_form(form): + """Create a local user from submitted admin form values.""" + username = (form.get("username") or "").strip() password = form.get("password") or "" confirm_password = (form.get("confirm_password") or "").strip() display_name = (form.get("display_name") or "").strip() avatar_url = (form.get("avatar_url") or "").strip() is_admin = form.get("is_admin") == "on" + auto_approve_artist_requests = form.get("auto_approve_artist_requests") == "on" if not username or not password: flash("Username and password are required.", "danger") @@ -47,6 +50,7 @@ def _create_user_from_form(form): display_name=display_name or None, avatar_url=avatar_url or None, is_admin=is_admin, + auto_approve_artist_requests=auto_approve_artist_requests, ) user.set_password(password) db.session.add(user) @@ -81,6 +85,8 @@ def _delete_user_from_form(form): def _edit_user_from_form(form): + """Update mutable user profile and access-control fields from admin input.""" + try: user_id = int(form.get("user_id", "0")) except ValueError: @@ -100,6 +106,7 @@ def _edit_user_from_form(form): # Update active status user.is_active = form.get("is_active") == "on" + user.auto_approve_artist_requests = form.get("auto_approve_artist_requests") == "on" # Update admin status with validation new_admin_status = form.get("is_admin") == "on" diff --git a/src/static/script.js b/src/static/script.js index 3238bdf..8887e57 100644 --- a/src/static/script.js +++ b/src/static/script.js @@ -133,11 +133,12 @@ const ai_helper_results = document.getElementById('ai-helper-results'); const ai_helper_submit = document.getElementById('ai-helper-submit'); const ai_helper_spinner = document.getElementById('ai-helper-spinner'); -var lidarr_items = []; -var is_admin = false; -var socket = io({ - withCredentials: true, -}); +var lidarr_items = []; +var is_admin = false; +var can_add_without_approval = false; +var socket = io({ + withCredentials: true, +}); var personalSourcesState = null; var personalDiscoveryState = { @@ -209,9 +210,10 @@ socket.on('connect', function () { socket.emit('personal_sources_poll'); }); -socket.on('user_info', function (data) { - is_admin = data.is_admin || false; -}); +socket.on('user_info', function (data) { + is_admin = data.is_admin || false; + can_add_without_approval = data.can_add_without_approval || is_admin; +}); function show_header_spinner() { if (header_spinner) { @@ -716,10 +718,10 @@ function append_artists(artists) { add_button.dataset.defaultText || add_button.textContent; // Set button text and handler based on admin status - if (is_admin) { - add_button.textContent = add_button.dataset.defaultText; - add_button.addEventListener('click', function () { - add_to_lidarr(artist.Name, add_button); + if (can_add_without_approval) { + add_button.textContent = add_button.dataset.defaultText; + add_button.addEventListener('click', function () { + add_to_lidarr(artist.Name, add_button); }); } else { add_button.textContent = 'Request'; diff --git a/src/templates/admin_users.html b/src/templates/admin_users.html index 46cfa63..54e974f 100644 --- a/src/templates/admin_users.html +++ b/src/templates/admin_users.html @@ -43,6 +43,10 @@ +
+ + +
@@ -84,6 +88,9 @@ {% else %} User {% endif %} + {% if user.auto_approve_artist_requests %} + Auto-approve + {% endif %} {% if user.id != current_user.id %} @@ -94,6 +101,7 @@ data-user-avatarurl="{{ user.avatar_url or '' }}" data-user-isadmin="{{ 'true' if user.is_admin else 'false' }}" data-user-isactive="{{ 'true' if user.is_active else 'false' }}" + data-user-autoapprove="{{ 'true' if user.auto_approve_artist_requests else 'false' }}" data-user-isoidc="{{ 'true' if user.oidc_id else 'false' }}"> Edit @@ -161,6 +169,11 @@ +
+ + +
+ @@ -185,6 +198,7 @@ document.getElementById('editUserModal').addEventListener('show.bs.modal', funct const avatarUrl = button.getAttribute('data-user-avatarurl'); const isAdmin = button.getAttribute('data-user-isadmin') === 'true'; const isActive = button.getAttribute('data-user-isactive') === 'true'; + const autoApprove = button.getAttribute('data-user-autoapprove') === 'true'; const isOidc = button.getAttribute('data-user-isoidc') === 'true'; document.getElementById('edit_user_id').value = userId; @@ -193,6 +207,7 @@ document.getElementById('editUserModal').addEventListener('show.bs.modal', funct document.getElementById('edit_avatar_url').value = avatarUrl; document.getElementById('edit_is_admin').checked = isAdmin; document.getElementById('edit_is_active').checked = isActive; + document.getElementById('edit_auto_approve_artist_requests').checked = autoApprove; // Show/hide OIDC warning const oidcWarning = document.getElementById('oidc_warning');