1f6217d347
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
114 lines
3.6 KiB
Python
114 lines
3.6 KiB
Python
from datetime import datetime
|
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, make_response, current_app
|
|
from flask_login import login_user, logout_user, login_required, current_user
|
|
from werkzeug.security import check_password_hash
|
|
import jwt
|
|
|
|
from app.extensions import db
|
|
from app.models.user import PortalUser
|
|
|
|
bp = Blueprint('auth', __name__)
|
|
|
|
|
|
def _issue_portal_cookie(user, response):
|
|
"""Sign and attach the platform-wide JWT cookie to a response."""
|
|
import time
|
|
expiry_hours = current_app.config['JWT_EXPIRY_HOURS']
|
|
now = int(time.time())
|
|
payload = {
|
|
'sub': user.username,
|
|
'user_id': user.id,
|
|
'email': user.email,
|
|
'role': user.role,
|
|
'apps': user.get_accessible_apps(),
|
|
'iss': 'enterprise-digital-platform',
|
|
'iat': now,
|
|
'exp': now + expiry_hours * 3600,
|
|
}
|
|
token = jwt.encode(payload, current_app.config['PORTAL_JWT_SECRET'], algorithm='HS256')
|
|
response.set_cookie(
|
|
current_app.config['PORTAL_COOKIE_NAME'],
|
|
token,
|
|
httponly=True,
|
|
samesite='Lax',
|
|
max_age=expiry_hours * 3600,
|
|
path='/',
|
|
)
|
|
return response
|
|
|
|
|
|
@bp.route('/login', methods=['GET', 'POST'])
|
|
def login():
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('dashboard.index'))
|
|
|
|
if request.method == 'POST':
|
|
username = request.form.get('username', '').strip()
|
|
password = request.form.get('password', '')
|
|
|
|
if not username or not password:
|
|
flash('Please enter username and password.', 'danger')
|
|
return render_template('auth/login.html')
|
|
|
|
user = PortalUser.query.filter_by(username=username, is_active=True).first()
|
|
if user and check_password_hash(user.password_hash, password):
|
|
user.last_login = datetime.utcnow()
|
|
db.session.commit()
|
|
login_user(user, remember=False)
|
|
|
|
next_page = request.args.get('next') or url_for('dashboard.index')
|
|
resp = make_response(redirect(next_page))
|
|
_issue_portal_cookie(user, resp)
|
|
return resp
|
|
|
|
flash('Invalid username or password.', 'danger')
|
|
|
|
return render_template('auth/login.html')
|
|
|
|
|
|
@bp.route('/logout')
|
|
@login_required
|
|
def logout():
|
|
logout_user()
|
|
resp = make_response(redirect(url_for('auth.login')))
|
|
resp.delete_cookie(current_app.config['PORTAL_COOKIE_NAME'], path='/')
|
|
flash('You have been signed out.', 'info')
|
|
return resp
|
|
|
|
|
|
@bp.route('/portal-return')
|
|
def portal_return():
|
|
"""
|
|
Re-establish a portal Flask-Login session from the JWT cookie.
|
|
|
|
Sub-apps link here instead of '/' so the user is always properly
|
|
logged in to the portal even if the session cookie expired while
|
|
they were working in a sub-app.
|
|
"""
|
|
# Already logged in — just go to the dashboard
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('dashboard.index'))
|
|
|
|
token = request.cookies.get(current_app.config['PORTAL_COOKIE_NAME'])
|
|
if not token:
|
|
return redirect(url_for('auth.login'))
|
|
|
|
try:
|
|
payload = jwt.decode(
|
|
token,
|
|
current_app.config['PORTAL_JWT_SECRET'],
|
|
algorithms=['HS256'],
|
|
options={'require': ['exp', 'sub', 'user_id']},
|
|
)
|
|
except jwt.InvalidTokenError:
|
|
return redirect(url_for('auth.login'))
|
|
|
|
user = PortalUser.query.get(payload.get('user_id'))
|
|
if not user or not user.is_active:
|
|
return redirect(url_for('auth.login'))
|
|
|
|
login_user(user, remember=False)
|
|
resp = make_response(redirect(url_for('dashboard.index')))
|
|
_issue_portal_cookie(user, resp) # refresh the JWT while we're here
|
|
return resp
|