Add per-user auto-approve toggle for artist additions

This commit is contained in:
Beda Schmid
2026-03-03 08:59:00 -03:00
parent a04ccdc7a0
commit ff853b1754
9 changed files with 234 additions and 20 deletions

7
doc/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Technical Documentation
This directory contains developer-facing technical documentation for Sonobarr.
## Index
- [User management and approval flow](./user-management.md)

36
doc/user-management.md Normal file
View File

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

View File

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

View File

@@ -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"<User id={self.id} username={self.username!r} admin={self.is_admin}>"
return (
f"<User id={self.id} username={self.username!r} "
f"admin={self.is_admin} auto_approve={self.auto_approve_artist_requests}>"
)
class ArtistRequest(db.Model):

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,10 @@
<input class="form-check-input" type="checkbox" id="is_admin" name="is_admin">
<label class="form-check-label" for="is_admin">Grant admin privileges</label>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="auto_approve_artist_requests" name="auto_approve_artist_requests">
<label class="form-check-label" for="auto_approve_artist_requests">Auto approve artist additions</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Create user</button>
</div>
@@ -84,6 +88,9 @@
{% else %}
<span class="badge bg-secondary">User</span>
{% endif %}
{% if user.auto_approve_artist_requests %}
<span class="badge bg-success ms-1">Auto-approve</span>
{% endif %}
</td>
<td class="text-end">
{% 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
</button>
@@ -161,6 +169,11 @@
<label class="form-check-label" for="edit_is_admin">Admin privileges</label>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="edit_auto_approve_artist_requests" name="auto_approve_artist_requests">
<label class="form-check-label" for="edit_auto_approve_artist_requests">Auto approve artist additions</label>
</div>
<div class="alert alert-warning py-2 px-3 small mb-0" id="oidc_warning" style="display: none;">
<strong>Note:</strong> This user authenticates via OIDC SSO. Admin status will be re-synced on their next login based on SSO group membership.
</div>
@@ -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');