feat: 3-tier role system across all platform apps
Portal: - Replace is_admin boolean with role column (admin/advanced/standard) - Settings UI: 3-tier portal role select + per-app role dropdowns - /portal-return endpoint: re-establishes session from JWT for sub-app back-links - /api/internal/nv-users: internal endpoint for NetworkView user sync - portal/migrate_roles.py: one-time DB migration script - Role badges (admin/advanced/standard) in topbar and settings table DigiServer: - Add editor and viewer roles (portal advanced->editor, standard->viewer) - PlaylistPermission model: grant viewer users edit access to specific playlists - app/utils/access.py: shared editor_required, admin_required, can_edit_playlist helpers - Content routes: editor_required on create/delete, per-playlist permission check on mutations - Admin: playlist_permissions route + template to manage viewer playlist grants - Base template: hide Admin nav for viewers, Portal button (⬡) returns to portal - content_list_new: hide create/delete for viewers; Manage vs View button per permission - manage_playlist_content: view-only mode when user lacks edit permission NetworkView: - backend/src/middleware/rbac.js: requireRole + requireWriteAccess helpers - site_permissions table: one site per advanced user - All mutating routes guarded (admin=all, advanced=assigned site, viewer=read-only) - Portal SSO auto-upsert: user row synced from X-Auth-Role on every request - GET /api/users: merges portal users list with local NV data (all 4 portal users visible) - GET/PUT /api/users/:id/site-permission: assign site to advanced user - Settings Users tab: role badges, site dropdown for advanced, (portal only) indicator - Sidebar: ⬡ Portal button between Settings and Logout - Frontend build: VITE_API_BASE=/networkview/api now set in start-dev.sh IT Assets / Server Monitor: - portal_sso.py updated: map advanced->editor/viewer, standard->readonly - AdminUser model: add editor role + is_editor property
This commit is contained in:
@@ -9,26 +9,15 @@ from typing import Optional
|
||||
|
||||
from app.extensions import db, bcrypt
|
||||
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
|
||||
from app.models.playlist_permission import PlaylistPermission
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.caddy_manager import CaddyConfigGenerator
|
||||
from app.utils.nginx_config_reader import get_nginx_status
|
||||
from app.utils.access import admin_required, editor_required
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin role for route access."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
flash('Please login to access this page.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
if current_user.role != 'admin':
|
||||
log_action('warning', f'Unauthorized admin access attempt by {current_user.username}')
|
||||
flash('You do not have permission to access this page.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
# admin_required and editor_required imported from app.utils.access
|
||||
|
||||
|
||||
@admin_bp.route('/')
|
||||
@@ -203,6 +192,58 @@ def user_management():
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
# ── Playlist permissions (viewer-role users) ───────────────────────────────────
|
||||
|
||||
@admin_bp.route('/user/<int:user_id>/playlist-permissions')
|
||||
@login_required
|
||||
@admin_required
|
||||
def playlist_permissions(user_id: int):
|
||||
"""Show/manage which playlists a viewer-role user may edit."""
|
||||
user = User.query.get_or_404(user_id)
|
||||
playlists = Playlist.query.order_by(Playlist.name).all()
|
||||
granted_ids = {
|
||||
p.playlist_id
|
||||
for p in PlaylistPermission.query.filter_by(user_id=user_id).all()
|
||||
}
|
||||
return render_template(
|
||||
'admin/playlist_permissions.html',
|
||||
target_user=user,
|
||||
playlists=playlists,
|
||||
granted_ids=granted_ids,
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/user/<int:user_id>/playlist-permissions/save', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def save_playlist_permissions(user_id: int):
|
||||
"""Save (overwrite) the playlist edit permissions for a viewer user."""
|
||||
user = User.query.get_or_404(user_id)
|
||||
all_playlists = Playlist.query.all()
|
||||
|
||||
# The form sends one checkbox per playlist: name="playlist_<id>" value="1"
|
||||
new_ids = {
|
||||
pl.id for pl in all_playlists
|
||||
if request.form.get(f'playlist_{pl.id}') == '1'
|
||||
}
|
||||
|
||||
# Current grants
|
||||
existing = {p.playlist_id: p for p in PlaylistPermission.query.filter_by(user_id=user_id).all()}
|
||||
|
||||
# Add new grants
|
||||
for pid in new_ids - set(existing.keys()):
|
||||
db.session.add(PlaylistPermission(user_id=user_id, playlist_id=pid))
|
||||
|
||||
# Remove revoked grants
|
||||
for pid in set(existing.keys()) - new_ids:
|
||||
db.session.delete(existing[pid])
|
||||
|
||||
db.session.commit()
|
||||
log_action('info', f'Playlist permissions updated for user "{user.username}" by {current_user.username}')
|
||||
flash(f'Playlist permissions updated for "{user.username}".', 'success')
|
||||
return redirect(url_for('admin.playlist_permissions', user_id=user_id))
|
||||
|
||||
|
||||
@admin_bp.route('/user/<int:user_id>/password', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Content blueprint - New playlist-centric workflow."""
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, jsonify, current_app)
|
||||
from flask_login import login_required
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
from typing import Optional
|
||||
import os
|
||||
@@ -15,6 +15,7 @@ from app.models import Content, Playlist, Player
|
||||
from app.models.playlist import playlist_content
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.uploads import process_video_file, set_upload_progress
|
||||
from app.utils.access import editor_required, can_edit_playlist, get_editable_playlist_ids
|
||||
|
||||
# Store for background processing status
|
||||
_background_tasks = {}
|
||||
@@ -30,12 +31,14 @@ def content_list():
|
||||
media_files = Content.query.order_by(Content.uploaded_at.desc()).limit(3).all() # Only last 3
|
||||
total_media_count = Content.query.count() # Total count for display
|
||||
players = Player.query.order_by(Player.name).all()
|
||||
|
||||
editable_ids = get_editable_playlist_ids(current_user)
|
||||
|
||||
return render_template('content/content_list_new.html',
|
||||
playlists=playlists,
|
||||
media_files=media_files,
|
||||
total_media_count=total_media_count,
|
||||
players=players)
|
||||
players=players,
|
||||
editable_ids=editable_ids)
|
||||
|
||||
|
||||
@content_bp.route('/media-library')
|
||||
@@ -68,6 +71,7 @@ def media_library():
|
||||
|
||||
@content_bp.route('/media/<int:media_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@editor_required
|
||||
def delete_media(media_id: int):
|
||||
"""Delete a media file and remove it from all playlists."""
|
||||
try:
|
||||
@@ -132,6 +136,7 @@ def delete_media(media_id: int):
|
||||
|
||||
@content_bp.route('/playlist/create', methods=['POST'])
|
||||
@login_required
|
||||
@editor_required
|
||||
def create_playlist():
|
||||
"""Create a new playlist."""
|
||||
try:
|
||||
@@ -170,6 +175,7 @@ def create_playlist():
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@editor_required
|
||||
def delete_playlist(playlist_id: int):
|
||||
"""Delete a playlist."""
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
@@ -200,27 +206,32 @@ def delete_playlist(playlist_id: int):
|
||||
def manage_playlist_content(playlist_id: int):
|
||||
"""Manage content in a specific playlist."""
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
can_edit = can_edit_playlist(current_user, playlist_id)
|
||||
|
||||
# Get content in playlist (ordered)
|
||||
playlist_content = playlist.get_content_ordered()
|
||||
|
||||
|
||||
# Get all available content not in this playlist.
|
||||
# Web links are created on demand per playlist, so they are not offered
|
||||
# as reusable library items here.
|
||||
all_content = Content.query.filter(Content.content_type != 'weblink').all()
|
||||
playlist_content_ids = {c.id for c in playlist_content}
|
||||
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
||||
|
||||
|
||||
return render_template('content/manage_playlist_content.html',
|
||||
playlist=playlist,
|
||||
playlist_content=playlist_content,
|
||||
available_content=available_content)
|
||||
available_content=available_content,
|
||||
can_edit=can_edit)
|
||||
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/add-content', methods=['POST'])
|
||||
@login_required
|
||||
def add_content_to_playlist(playlist_id: int):
|
||||
"""Add content to playlist."""
|
||||
if not can_edit_playlist(current_user, playlist_id):
|
||||
flash('You do not have permission to edit this playlist.', 'danger')
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
@@ -351,6 +362,9 @@ def add_weblink():
|
||||
@login_required
|
||||
def add_weblink_to_playlist(playlist_id: int):
|
||||
"""Create a web link content item and add it to the playlist."""
|
||||
if not can_edit_playlist(current_user, playlist_id):
|
||||
flash('You do not have permission to edit this playlist.', 'danger')
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
@@ -417,6 +431,9 @@ def add_weblink_to_playlist(playlist_id: int):
|
||||
@login_required
|
||||
def remove_content_from_playlist(playlist_id: int, content_id: int):
|
||||
"""Remove content from playlist."""
|
||||
if not can_edit_playlist(current_user, playlist_id):
|
||||
flash('You do not have permission to edit this playlist.', 'danger')
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
@@ -454,6 +471,8 @@ def remove_content_from_playlist(playlist_id: int, content_id: int):
|
||||
@login_required
|
||||
def bulk_remove_from_playlist(playlist_id: int):
|
||||
"""Remove multiple content items from playlist."""
|
||||
if not can_edit_playlist(current_user, playlist_id):
|
||||
return jsonify({'success': False, 'message': 'Permission denied'}), 403
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
@@ -496,6 +515,8 @@ def bulk_remove_from_playlist(playlist_id: int):
|
||||
@login_required
|
||||
def reorder_playlist_content(playlist_id: int):
|
||||
"""Reorder content in playlist."""
|
||||
if not can_edit_playlist(current_user, playlist_id):
|
||||
return jsonify({'success': False, 'message': 'Permission denied'}), 403
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
|
||||
@@ -40,8 +40,14 @@ def sync_user():
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = (data.get('username') or '').strip()
|
||||
role_raw = (data.get('role') or 'user').strip()
|
||||
role = 'admin' if role_raw == 'admin' else 'user'
|
||||
role_raw = (data.get('role') or 'viewer').strip()
|
||||
# Accept both old ('user') and new ('advanced'/'standard') role names from portal
|
||||
if role_raw == 'admin':
|
||||
role = 'admin'
|
||||
elif role_raw in ('advanced', 'editor'):
|
||||
role = 'editor'
|
||||
else:
|
||||
role = 'viewer'
|
||||
|
||||
if not username:
|
||||
return jsonify({'error': 'username required'}), 400
|
||||
|
||||
Reference in New Issue
Block a user