mirror of
https://github.com/Dodelidoo-Labs/sonobarr.git
synced 2026-04-29 02:12:38 -04:00
Add per-user auto-approve toggle for artist additions
This commit is contained in:
7
doc/README.md
Normal file
7
doc/README.md
Normal 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
36
doc/user-management.md
Normal 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.
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user