IT Assets: add role-based auth system and portal user sync
Auth: - Fix local login (was redirecting to portal; now authenticates AdminUser directly) - Portal SSO still takes priority in production via nginx headers Role system (admin | editor | readonly): - New app/utils/decorators.py with editor_required and admin_required decorators - All write routes protected with editor_required (create/edit/delete/import/mask) - Settings user management protected with admin_required - Sidebar hides write-only links for readonly users - Dashboard quick actions and list page buttons hidden for readonly Settings page: - Role colour badges (admin=red, editor=blue, readonly=grey) - Inline role changer per user (dropdown auto-submit) - Reset password modal per user - Delete user button with confirmation - Add user form includes role selector with legend Portal user sync: - New /internal/sync-user endpoint receives user pre-creation from portal - INTERNAL_SYNC_SECRET added to config - portal/config.py: added internal_url for itassets app so _sync_user_to_app works
This commit is contained in:
@@ -38,6 +38,7 @@ def create_app(config_name='default'):
|
|||||||
from app.routes.audit import bp as audit_bp
|
from app.routes.audit import bp as audit_bp
|
||||||
from app.routes.settings import bp as settings_bp
|
from app.routes.settings import bp as settings_bp
|
||||||
from app.routes.doc_templates import bp as doc_templates_bp
|
from app.routes.doc_templates import bp as doc_templates_bp
|
||||||
|
from app.routes.internal import bp as internal_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
@@ -48,6 +49,7 @@ def create_app(config_name='default'):
|
|||||||
app.register_blueprint(audit_bp)
|
app.register_blueprint(audit_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(doc_templates_bp)
|
app.register_blueprint(doc_templates_bp)
|
||||||
|
app.register_blueprint(internal_bp)
|
||||||
|
|
||||||
# Inject common template variables
|
# Inject common template variables
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ from app.routes.assignments import bp as assignments_bp
|
|||||||
from app.routes.paperwork import bp as paperwork_bp
|
from app.routes.paperwork import bp as paperwork_bp
|
||||||
from app.routes.audit import bp as audit_bp
|
from app.routes.audit import bp as audit_bp
|
||||||
from app.routes.settings import bp as settings_bp
|
from app.routes.settings import bp as settings_bp
|
||||||
|
from app.routes.internal import bp as internal_bp
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'auth_bp', 'dashboard_bp', 'users_bp', 'assets_bp',
|
'auth_bp', 'dashboard_bp', 'users_bp', 'assets_bp',
|
||||||
'assignments_bp', 'paperwork_bp', 'audit_bp', 'settings_bp',
|
'assignments_bp', 'paperwork_bp', 'audit_bp', 'settings_bp', 'internal_bp',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from flask import (Blueprint, render_template, redirect, url_for,
|
|||||||
flash, request, current_app, jsonify)
|
flash, request, current_app, jsonify)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.utils.decorators import editor_required
|
||||||
from app.models.asset import Asset, ASSET_TYPES, ASSET_STATUSES
|
from app.models.asset import Asset, ASSET_TYPES, ASSET_STATUSES
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.compliance_check import ComplianceCheck
|
from app.models.compliance_check import ComplianceCheck
|
||||||
@@ -82,6 +83,7 @@ def index():
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@bp.route('/new', methods=['GET', 'POST'])
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def create():
|
def create():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
sn = request.form.get('serial_number', '').strip()
|
sn = request.form.get('serial_number', '').strip()
|
||||||
@@ -204,6 +206,7 @@ def detail(asset_id):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@bp.route('/<int:asset_id>/edit', methods=['GET', 'POST'])
|
@bp.route('/<int:asset_id>/edit', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def edit(asset_id):
|
def edit(asset_id):
|
||||||
asset = Asset.query.get_or_404(asset_id)
|
asset = Asset.query.get_or_404(asset_id)
|
||||||
|
|
||||||
@@ -267,6 +270,7 @@ _COMPLIANCE_FIELDS = {
|
|||||||
|
|
||||||
@bp.route('/<int:asset_id>/compliance', methods=['POST'])
|
@bp.route('/<int:asset_id>/compliance', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def update_compliance(asset_id):
|
def update_compliance(asset_id):
|
||||||
asset = Asset.query.get_or_404(asset_id)
|
asset = Asset.query.get_or_404(asset_id)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from flask import (Blueprint, render_template, redirect, url_for,
|
|||||||
flash, request, current_app)
|
flash, request, current_app)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.utils.decorators import editor_required
|
||||||
from app.models.assignment import Assignment
|
from app.models.assignment import Assignment
|
||||||
from app.models.asset import Asset
|
from app.models.asset import Asset
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -46,6 +47,7 @@ def index():
|
|||||||
|
|
||||||
@bp.route('/new', methods=['GET', 'POST'])
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def create():
|
def create():
|
||||||
# Pre-fill from query params (used from asset / user detail pages)
|
# Pre-fill from query params (used from asset / user detail pages)
|
||||||
preselect_asset_id = request.args.get('asset_id', type=int)
|
preselect_asset_id = request.args.get('asset_id', type=int)
|
||||||
@@ -110,6 +112,7 @@ def create():
|
|||||||
|
|
||||||
@bp.route('/<int:assignment_id>/return', methods=['POST'])
|
@bp.route('/<int:assignment_id>/return', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def return_asset(assignment_id):
|
def return_asset(assignment_id):
|
||||||
assignment = Assignment.query.get_or_404(assignment_id)
|
assignment = Assignment.query.get_or_404(assignment_id)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
from flask import Blueprint, redirect, url_for, current_app
|
from datetime import datetime
|
||||||
from flask_login import logout_user, login_required
|
from flask import Blueprint, redirect, url_for, render_template, request, flash
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
|
||||||
bp = Blueprint('auth', __name__)
|
bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/login', methods=['GET', 'POST'])
|
@bp.route('/login', methods=['GET', 'POST'])
|
||||||
def login():
|
def login():
|
||||||
# Authentication is handled by the EDP portal via nginx SSO.
|
# Portal SSO (nginx headers) handles production login automatically.
|
||||||
# Redirect any direct login attempts to the portal.
|
# This route handles direct / standalone access.
|
||||||
return redirect(current_app.config.get('PORTAL_LOGIN_URL', 'http://localhost:8080/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', '')
|
||||||
|
user = AdminUser.query.filter_by(username=username, is_active=True).first()
|
||||||
|
if user and user.check_password(password):
|
||||||
|
login_user(user, remember=False)
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
next_page = request.args.get('next') or url_for('dashboard.index')
|
||||||
|
return redirect(next_page)
|
||||||
|
flash('Invalid username or password.', 'danger')
|
||||||
|
|
||||||
|
return render_template('auth/login.html')
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/logout')
|
@bp.route('/logout')
|
||||||
@login_required
|
@login_required
|
||||||
def logout():
|
def logout():
|
||||||
logout_user()
|
logout_user()
|
||||||
return redirect(current_app.config.get('PORTAL_LOGOUT_URL', 'http://localhost:8080/logout'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from flask import (Blueprint, render_template, redirect, url_for,
|
|||||||
flash, request, current_app, send_from_directory, jsonify)
|
flash, request, current_app, send_from_directory, jsonify)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
from app.utils.decorators import editor_required
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.document_template import DocumentTemplate
|
from app.models.document_template import DocumentTemplate
|
||||||
from app.models.paperwork import DOC_TYPES
|
from app.models.paperwork import DOC_TYPES
|
||||||
@@ -54,6 +55,7 @@ def index():
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@bp.route('/upload', methods=['GET', 'POST'])
|
@bp.route('/upload', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def upload():
|
def upload():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
name = request.form.get('name', '').strip()
|
name = request.form.get('name', '').strip()
|
||||||
@@ -134,6 +136,7 @@ def download(tpl_id):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@bp.route('/<int:tpl_id>/rescan', methods=['POST'])
|
@bp.route('/<int:tpl_id>/rescan', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def rescan(tpl_id):
|
def rescan(tpl_id):
|
||||||
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||||
folder = _template_folder(current_app)
|
folder = _template_folder(current_app)
|
||||||
@@ -153,6 +156,7 @@ def rescan(tpl_id):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@bp.route('/<int:tpl_id>/edit', methods=['GET', 'POST'])
|
@bp.route('/<int:tpl_id>/edit', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def edit(tpl_id):
|
def edit(tpl_id):
|
||||||
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@@ -170,6 +174,7 @@ def edit(tpl_id):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@bp.route('/<int:tpl_id>/delete', methods=['POST'])
|
@bp.route('/<int:tpl_id>/delete', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def delete(tpl_id):
|
def delete(tpl_id):
|
||||||
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||||
# Check if any documents were generated from this template
|
# Check if any documents were generated from this template
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Internal routes — called by the EDP portal (not by browsers).
|
||||||
|
|
||||||
|
POST /internal/sync-user
|
||||||
|
Called by the portal whenever it grants or updates a user's access to the
|
||||||
|
itassets app. Ensures the local AdminUser record exists and has the correct
|
||||||
|
role before the user ever logs in for the first time.
|
||||||
|
|
||||||
|
Body (JSON): { "username": "alice", "role": "advanced" }
|
||||||
|
Header: X-Internal-Token: <INTERNAL_SYNC_SECRET>
|
||||||
|
|
||||||
|
Portal roles are mapped to local roles:
|
||||||
|
admin → admin
|
||||||
|
advanced → editor
|
||||||
|
standard → readonly
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.utils.portal_sso import _portal_role_to_local
|
||||||
|
|
||||||
|
bp = Blueprint('internal', __name__, url_prefix='/internal')
|
||||||
|
|
||||||
|
|
||||||
|
def _check_token():
|
||||||
|
expected = current_app.config.get('INTERNAL_SYNC_SECRET', '')
|
||||||
|
provided = request.headers.get('X-Internal-Token', '')
|
||||||
|
# Use hmac.compare_digest equivalent via secrets.compare_digest
|
||||||
|
if not expected or not secrets.compare_digest(expected, provided):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/sync-user', methods=['POST'])
|
||||||
|
def sync_user():
|
||||||
|
if not _check_token():
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = (data.get('username') or '').strip()
|
||||||
|
portal_role = (data.get('role') or 'standard').strip()
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
return jsonify({'error': 'username is required'}), 400
|
||||||
|
|
||||||
|
local_role = _portal_role_to_local(portal_role)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = AdminUser.query.filter_by(username=username).first()
|
||||||
|
if user:
|
||||||
|
if user.role != local_role:
|
||||||
|
user.role = local_role
|
||||||
|
db.session.commit()
|
||||||
|
status = 'updated'
|
||||||
|
else:
|
||||||
|
user = AdminUser(
|
||||||
|
username=username,
|
||||||
|
email=f'{username}@portal.local',
|
||||||
|
full_name=username,
|
||||||
|
role=local_role,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
user.set_password(secrets.token_hex(32)) # random unusable password
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
status = 'created'
|
||||||
|
|
||||||
|
return jsonify({'status': status, 'username': username, 'role': local_role}), 200
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
current_app.logger.error('sync-user failed: %s', exc)
|
||||||
|
return jsonify({'error': str(exc)}), 500
|
||||||
@@ -5,6 +5,7 @@ from flask import (Blueprint, render_template, redirect, url_for,
|
|||||||
flash, request, current_app, send_from_directory, abort, jsonify)
|
flash, request, current_app, send_from_directory, abort, jsonify)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.utils.decorators import editor_required
|
||||||
from app.models.paperwork import Paperwork, DOC_TYPES
|
from app.models.paperwork import Paperwork, DOC_TYPES
|
||||||
from app.models.document_template import DocumentTemplate
|
from app.models.document_template import DocumentTemplate
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -54,6 +55,7 @@ def index():
|
|||||||
|
|
||||||
@bp.route('/new', methods=['GET', 'POST'])
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def create():
|
def create():
|
||||||
preselect_user_id = request.args.get('user_id', type=int)
|
preselect_user_id = request.args.get('user_id', type=int)
|
||||||
preselect_asset_id = request.args.get('asset_id', type=int)
|
preselect_asset_id = request.args.get('asset_id', type=int)
|
||||||
@@ -177,6 +179,7 @@ def download_docx(doc_id):
|
|||||||
|
|
||||||
@bp.route('/<int:doc_id>/regenerate', methods=['POST'])
|
@bp.route('/<int:doc_id>/regenerate', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def regenerate(doc_id):
|
def regenerate(doc_id):
|
||||||
doc = Paperwork.query.get_or_404(doc_id)
|
doc = Paperwork.query.get_or_404(doc_id)
|
||||||
app = current_app._get_current_object()
|
app = current_app._get_current_object()
|
||||||
@@ -204,6 +207,7 @@ def regenerate(doc_id):
|
|||||||
|
|
||||||
@bp.route('/<int:doc_id>/sign', methods=['POST'])
|
@bp.route('/<int:doc_id>/sign', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def sign(doc_id):
|
def sign(doc_id):
|
||||||
"""Record a signature on a document."""
|
"""Record a signature on a document."""
|
||||||
doc = Paperwork.query.get_or_404(doc_id)
|
doc = Paperwork.query.get_or_404(doc_id)
|
||||||
@@ -234,6 +238,7 @@ def sign(doc_id):
|
|||||||
|
|
||||||
@bp.route('/<int:doc_id>/unsign', methods=['POST'])
|
@bp.route('/<int:doc_id>/unsign', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def unsign(doc_id):
|
def unsign(doc_id):
|
||||||
doc = Paperwork.query.get_or_404(doc_id)
|
doc = Paperwork.query.get_or_404(doc_id)
|
||||||
doc.signed_at = None
|
doc.signed_at = None
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request,
|
|||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.utils.decorators import admin_required
|
||||||
|
|
||||||
bp = Blueprint('settings', __name__, url_prefix='/settings')
|
bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||||
|
|
||||||
@@ -15,12 +16,16 @@ def index():
|
|||||||
|
|
||||||
@bp.route('/admin/new', methods=['POST'])
|
@bp.route('/admin/new', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@admin_required
|
||||||
def create_admin():
|
def create_admin():
|
||||||
username = request.form.get('username', '').strip()
|
username = request.form.get('username', '').strip()
|
||||||
email = request.form.get('email', '').strip()
|
email = request.form.get('email', '').strip()
|
||||||
full_name = request.form.get('full_name', '').strip()
|
full_name = request.form.get('full_name', '').strip()
|
||||||
password = request.form.get('password', '')
|
password = request.form.get('password', '')
|
||||||
role = request.form.get('role', 'admin')
|
role = request.form.get('role', 'readonly')
|
||||||
|
|
||||||
|
if role not in ('admin', 'editor', 'readonly'):
|
||||||
|
role = 'readonly'
|
||||||
|
|
||||||
if not username or not email or not password:
|
if not username or not email or not password:
|
||||||
flash('Username, email and password are required.', 'danger')
|
flash('Username, email and password are required.', 'danger')
|
||||||
@@ -34,12 +39,13 @@ def create_admin():
|
|||||||
admin.set_password(password)
|
admin.set_password(password)
|
||||||
db.session.add(admin)
|
db.session.add(admin)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Admin user "{username}" created.', 'success')
|
flash(f'User "{username}" created with role "{role}".', 'success')
|
||||||
return redirect(url_for('settings.index'))
|
return redirect(url_for('settings.index'))
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/admin/<int:admin_id>/toggle', methods=['POST'])
|
@bp.route('/admin/<int:admin_id>/toggle', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@admin_required
|
||||||
def toggle_admin(admin_id):
|
def toggle_admin(admin_id):
|
||||||
admin = AdminUser.query.get_or_404(admin_id)
|
admin = AdminUser.query.get_or_404(admin_id)
|
||||||
if admin.id == current_user.id:
|
if admin.id == current_user.id:
|
||||||
@@ -48,5 +54,53 @@ def toggle_admin(admin_id):
|
|||||||
admin.is_active = not admin.is_active
|
admin.is_active = not admin.is_active
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
status = 'activated' if admin.is_active else 'deactivated'
|
status = 'activated' if admin.is_active else 'deactivated'
|
||||||
flash(f'Admin "{admin.username}" {status}.', 'success')
|
flash(f'User "{admin.username}" {status}.', 'success')
|
||||||
|
return redirect(url_for('settings.index'))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/admin/<int:admin_id>/change-role', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def change_role(admin_id):
|
||||||
|
admin = AdminUser.query.get_or_404(admin_id)
|
||||||
|
if admin.id == current_user.id:
|
||||||
|
flash('You cannot change your own role.', 'danger')
|
||||||
|
return redirect(url_for('settings.index'))
|
||||||
|
role = request.form.get('role', 'readonly')
|
||||||
|
if role not in ('admin', 'editor', 'readonly'):
|
||||||
|
flash('Invalid role.', 'danger')
|
||||||
|
return redirect(url_for('settings.index'))
|
||||||
|
admin.role = role
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Role for "{admin.username}" updated to "{role}".', 'success')
|
||||||
|
return redirect(url_for('settings.index'))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/admin/<int:admin_id>/reset-password', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def reset_password(admin_id):
|
||||||
|
admin = AdminUser.query.get_or_404(admin_id)
|
||||||
|
new_password = request.form.get('new_password', '')
|
||||||
|
if len(new_password) < 8:
|
||||||
|
flash('Password must be at least 8 characters.', 'danger')
|
||||||
|
return redirect(url_for('settings.index'))
|
||||||
|
admin.set_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Password for "{admin.username}" has been reset.', 'success')
|
||||||
|
return redirect(url_for('settings.index'))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/admin/<int:admin_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_admin(admin_id):
|
||||||
|
admin = AdminUser.query.get_or_404(admin_id)
|
||||||
|
if admin.id == current_user.id:
|
||||||
|
flash('You cannot delete your own account.', 'danger')
|
||||||
|
return redirect(url_for('settings.index'))
|
||||||
|
username = admin.username
|
||||||
|
db.session.delete(admin)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'User "{username}" deleted.', 'success')
|
||||||
return redirect(url_for('settings.index'))
|
return redirect(url_for('settings.index'))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from flask import (Blueprint, render_template, redirect, url_for,
|
|||||||
flash, request, current_app, jsonify)
|
flash, request, current_app, jsonify)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.utils.decorators import editor_required
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.services.csv_service import parse_users_csv
|
from app.services.csv_service import parse_users_csv
|
||||||
@@ -63,6 +64,7 @@ def index():
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@bp.route('/new', methods=['GET', 'POST'])
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def create():
|
def create():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
windows_id = request.form.get('windows_id', '').strip()
|
windows_id = request.form.get('windows_id', '').strip()
|
||||||
@@ -113,6 +115,7 @@ def detail(user_id):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
|
@bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def edit(user_id):
|
def edit(user_id):
|
||||||
user = User.query.get_or_404(user_id)
|
user = User.query.get_or_404(user_id)
|
||||||
|
|
||||||
@@ -148,6 +151,7 @@ def edit(user_id):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@bp.route('/<int:user_id>/mask', methods=['POST'])
|
@bp.route('/<int:user_id>/mask', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def mask(user_id):
|
def mask(user_id):
|
||||||
user = User.query.get_or_404(user_id)
|
user = User.query.get_or_404(user_id)
|
||||||
|
|
||||||
@@ -191,6 +195,7 @@ def import_page():
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@bp.route('/import/csv', methods=['POST'])
|
@bp.route('/import/csv', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def import_csv():
|
def import_csv():
|
||||||
file = request.files.get('csv_file')
|
file = request.files.get('csv_file')
|
||||||
if not file or not file.filename.endswith('.csv'):
|
if not file or not file.filename.endswith('.csv'):
|
||||||
@@ -249,6 +254,7 @@ def import_csv():
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@bp.route('/import/ldap', methods=['POST'])
|
@bp.route('/import/ldap', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@editor_required
|
||||||
def import_ldap():
|
def import_ldap():
|
||||||
if not current_app.config.get('LDAP_SERVER'):
|
if not current_app.config.get('LDAP_SERVER'):
|
||||||
flash('LDAP server is not configured. Update Settings first.', 'danger')
|
flash('LDAP server is not configured. Update Settings first.', 'danger')
|
||||||
|
|||||||
@@ -127,40 +127,48 @@
|
|||||||
class="nav-link {% if request.blueprint == 'users' %}active{% endif %}">
|
class="nav-link {% if request.blueprint == 'users' %}active{% endif %}">
|
||||||
<i class="bi bi-people-fill"></i> Users
|
<i class="bi bi-people-fill"></i> Users
|
||||||
</a>
|
</a>
|
||||||
|
{% if current_user.is_editor %}
|
||||||
<a href="{{ url_for('users.import_page') }}"
|
<a href="{{ url_for('users.import_page') }}"
|
||||||
class="nav-link {% if request.endpoint == 'users.import_page' %}active{% endif %}">
|
class="nav-link {% if request.endpoint == 'users.import_page' %}active{% endif %}">
|
||||||
<i class="bi bi-cloud-download"></i> Import Users
|
<i class="bi bi-cloud-download"></i> Import Users
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="nav-section">Hardware</div>
|
<div class="nav-section">Hardware</div>
|
||||||
<a href="{{ url_for('assets.index') }}"
|
<a href="{{ url_for('assets.index') }}"
|
||||||
class="nav-link {% if request.blueprint == 'assets' %}active{% endif %}">
|
class="nav-link {% if request.blueprint == 'assets' %}active{% endif %}">
|
||||||
<i class="bi bi-laptop"></i> Assets
|
<i class="bi bi-laptop"></i> Assets
|
||||||
</a>
|
</a>
|
||||||
|
{% if current_user.is_editor %}
|
||||||
<a href="{{ url_for('assets.create') }}"
|
<a href="{{ url_for('assets.create') }}"
|
||||||
class="nav-link {% if request.endpoint == 'assets.create' %}active{% endif %}">
|
class="nav-link {% if request.endpoint == 'assets.create' %}active{% endif %}">
|
||||||
<i class="bi bi-plus-circle"></i> Add Asset
|
<i class="bi bi-plus-circle"></i> Add Asset
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="nav-section">Assignments</div>
|
<div class="nav-section">Assignments</div>
|
||||||
<a href="{{ url_for('assignments.index') }}"
|
<a href="{{ url_for('assignments.index') }}"
|
||||||
class="nav-link {% if request.blueprint == 'assignments' %}active{% endif %}">
|
class="nav-link {% if request.blueprint == 'assignments' %}active{% endif %}">
|
||||||
<i class="bi bi-arrow-left-right"></i> Assignments
|
<i class="bi bi-arrow-left-right"></i> Assignments
|
||||||
</a>
|
</a>
|
||||||
|
{% if current_user.is_editor %}
|
||||||
<a href="{{ url_for('assignments.create') }}"
|
<a href="{{ url_for('assignments.create') }}"
|
||||||
class="nav-link {% if request.endpoint == 'assignments.create' %}active{% endif %}">
|
class="nav-link {% if request.endpoint == 'assignments.create' %}active{% endif %}">
|
||||||
<i class="bi bi-plus-circle"></i> Assign Asset
|
<i class="bi bi-plus-circle"></i> Assign Asset
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="nav-section">Documents</div>
|
<div class="nav-section">Documents</div>
|
||||||
<a href="{{ url_for('paperwork.index') }}"
|
<a href="{{ url_for('paperwork.index') }}"
|
||||||
class="nav-link {% if request.blueprint == 'paperwork' %}active{% endif %}">
|
class="nav-link {% if request.blueprint == 'paperwork' %}active{% endif %}">
|
||||||
<i class="bi bi-file-earmark-text"></i> Paperwork
|
<i class="bi bi-file-earmark-text"></i> Paperwork
|
||||||
</a>
|
</a>
|
||||||
|
{% if current_user.is_editor %}
|
||||||
<a href="{{ url_for('paperwork.create') }}"
|
<a href="{{ url_for('paperwork.create') }}"
|
||||||
class="nav-link {% if request.endpoint == 'paperwork.create' %}active{% endif %}">
|
class="nav-link {% if request.endpoint == 'paperwork.create' %}active{% endif %}">
|
||||||
<i class="bi bi-file-earmark-plus"></i> New Document
|
<i class="bi bi-file-earmark-plus"></i> New Document
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
<a href="{{ url_for('doc_templates.index') }}"
|
<a href="{{ url_for('doc_templates.index') }}"
|
||||||
class="nav-link {% if request.blueprint == 'doc_templates' %}active{% endif %}">
|
class="nav-link {% if request.blueprint == 'doc_templates' %}active{% endif %}">
|
||||||
<i class="bi bi-file-earmark-word"></i> Templates
|
<i class="bi bi-file-earmark-word"></i> Templates
|
||||||
@@ -179,6 +187,9 @@
|
|||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<i class="bi bi-person-circle me-1"></i>
|
<i class="bi bi-person-circle me-1"></i>
|
||||||
<strong>{{ current_user.username }}</strong>
|
<strong>{{ current_user.username }}</strong>
|
||||||
|
<span class="badge ms-1 {% if current_user.role == 'admin' %}bg-danger{% elif current_user.role == 'editor' %}bg-primary{% else %}bg-secondary{% endif %}" style="font-size:.65rem;">
|
||||||
|
{{ current_user.role }}
|
||||||
|
</span>
|
||||||
<a href="{{ url_for('auth.logout') }}" class="ms-2 text-warning text-decoration-none">
|
<a href="{{ url_for('auth.logout') }}" class="ms-2 text-warning text-decoration-none">
|
||||||
<i class="bi bi-box-arrow-right"></i>
|
<i class="bi bi-box-arrow-right"></i>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -102,6 +102,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Quick Actions ──────────────────────────────────────────── -->
|
<!-- ── Quick Actions ──────────────────────────────────────────── -->
|
||||||
|
{% if current_user.is_editor %}
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
@@ -123,6 +124,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- ── Recent Assignments ─────────────────────────────────────── -->
|
<!-- ── Recent Assignments ─────────────────────────────────────── -->
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
|
|||||||
@@ -11,50 +11,79 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<!-- Admin users -->
|
<!-- App Users -->
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-header bg-white fw-semibold py-3">
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
<i class="bi bi-person-gear me-2 text-primary"></i>Admin Users
|
<i class="bi bi-person-gear me-2 text-primary"></i>Application Users
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover mb-0">
|
<table class="table table-sm table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr><th>Username</th><th>Full Name</th><th>Email</th><th>Role</th><th>Last Login</th><th>Active</th><th></th></tr>
|
<tr><th>Username</th><th>Full Name</th><th>Email</th><th>Role</th><th>Last Login</th><th>Active</th>
|
||||||
|
{% if current_user.is_admin %}<th></th>{% endif %}
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for a in admins %}
|
{% for a in admins %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{ a.username }}</strong></td>
|
<td><strong>{{ a.username }}</strong>{% if a.id == current_user.id %} <span class="badge bg-light text-secondary">you</span>{% endif %}</td>
|
||||||
<td>{{ a.full_name or '—' }}</td>
|
<td>{{ a.full_name or '—' }}</td>
|
||||||
<td>{{ a.email }}</td>
|
<td>{{ a.email }}</td>
|
||||||
<td><span class="badge bg-secondary">{{ a.role }}</span></td>
|
<td>
|
||||||
|
<span class="badge {% if a.role == 'admin' %}bg-danger{% elif a.role == 'editor' %}bg-primary{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ a.role }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td>{{ a.last_login.strftime('%d/%m/%Y') if a.last_login else '—' }}</td>
|
<td>{{ a.last_login.strftime('%d/%m/%Y') if a.last_login else '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if a.is_active %}
|
{% if a.is_active %}<span class="badge bg-success">Active</span>
|
||||||
<span class="badge bg-success">Active</span>
|
{% else %}<span class="badge bg-secondary">Inactive</span>{% endif %}
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary">Inactive</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
{% if current_user.is_admin %}
|
||||||
|
<td class="text-end">
|
||||||
{% if a.id != current_user.id %}
|
{% if a.id != current_user.id %}
|
||||||
<form method="POST" action="{{ url_for('settings.toggle_admin', admin_id=a.id) }}" class="d-inline">
|
<div class="d-flex gap-1 justify-content-end flex-wrap">
|
||||||
<button type="submit" class="btn btn-xs btn-sm btn-outline-{{ 'warning' if a.is_active else 'success' }} py-0 px-2">
|
<!-- Toggle active -->
|
||||||
{{ 'Deactivate' if a.is_active else 'Activate' }}
|
<form method="POST" action="{{ url_for('settings.toggle_admin', admin_id=a.id) }}" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-xs btn-sm btn-outline-{{ 'warning' if a.is_active else 'success' }} py-0 px-2">
|
||||||
|
{{ 'Deactivate' if a.is_active else 'Activate' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<!-- Change role -->
|
||||||
|
<form method="POST" action="{{ url_for('settings.change_role', admin_id=a.id) }}" class="d-inline">
|
||||||
|
<select name="role" class="form-select form-select-sm d-inline-block w-auto py-0" onchange="this.form.submit()">
|
||||||
|
<option value="admin" {% if a.role == 'admin' %}selected{% endif %}>admin</option>
|
||||||
|
<option value="editor" {% if a.role == 'editor' %}selected{% endif %}>editor</option>
|
||||||
|
<option value="readonly" {% if a.role == 'readonly' %}selected{% endif %}>readonly</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<!-- Reset password -->
|
||||||
|
<button type="button" class="btn btn-xs btn-sm btn-outline-secondary py-0 px-2"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#pwModal{{ a.id }}">
|
||||||
|
<i class="bi bi-key"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
<!-- Delete -->
|
||||||
|
<form method="POST" action="{{ url_for('settings.delete_admin', admin_id=a.id) }}" class="d-inline"
|
||||||
|
onsubmit="return confirm('Delete user {{ a.username }}? This cannot be undone.')">
|
||||||
|
<button type="submit" class="btn btn-xs btn-sm btn-outline-danger py-0 px-2">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add admin form -->
|
<!-- Add user form (admin only) -->
|
||||||
|
{% if current_user.is_admin %}
|
||||||
<div class="card-footer bg-white">
|
<div class="card-footer bg-white">
|
||||||
<h6 class="fw-semibold mb-3 mt-1">Add Admin User</h6>
|
<h6 class="fw-semibold mb-3 mt-1"><i class="bi bi-person-plus me-1"></i>Add User</h6>
|
||||||
<form method="POST" action="{{ url_for('settings.create_admin') }}">
|
<form method="POST" action="{{ url_for('settings.create_admin') }}">
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -69,12 +98,25 @@
|
|||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<input type="password" name="password" class="form-control form-control-sm" placeholder="Password" required minlength="8">
|
<input type="password" name="password" class="form-control form-control-sm" placeholder="Password" required minlength="8">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-1">
|
<div class="col-md-2">
|
||||||
<button type="submit" class="btn btn-sm btn-primary w-100">Add</button>
|
<select name="role" class="form-select form-select-sm">
|
||||||
|
<option value="readonly">readonly</option>
|
||||||
|
<option value="editor">editor</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Add</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<small class="text-muted mt-1 d-block">
|
||||||
|
<strong>readonly</strong> – view only |
|
||||||
|
<strong>editor</strong> – create & edit data |
|
||||||
|
<strong>admin</strong> – full access including user management
|
||||||
|
</small>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,9 +142,11 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
|
{% if current_user.is_editor %}
|
||||||
<a href="{{ url_for('users.import_page') }}" class="btn btn-sm btn-outline-primary">
|
<a href="{{ url_for('users.import_page') }}" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-arrow-repeat me-1"></i>Go to Import / Sync
|
<i class="bi bi-arrow-repeat me-1"></i>Go to Import / Sync
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,4 +167,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Password reset modals (one per user, admin only) -->
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
{% for a in admins %}
|
||||||
|
{% if a.id != current_user.id %}
|
||||||
|
<div class="modal fade" id="pwModal{{ a.id }}" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-sm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="POST" action="{{ url_for('settings.reset_password', admin_id=a.id) }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title">Reset password — {{ a.username }}</h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="password" name="new_password" class="form-control" placeholder="New password (min 8 chars)" required minlength="8">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Reset</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||||
<h1><i class="bi bi-people-fill me-2"></i>Users</h1>
|
<h1><i class="bi bi-people-fill me-2"></i>Users</h1>
|
||||||
|
{% if current_user.is_editor %}
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<a href="{{ url_for('users.import_page') }}" class="btn btn-outline-secondary btn-sm">
|
<a href="{{ url_for('users.import_page') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-cloud-download me-1"></i>Import
|
<i class="bi bi-cloud-download me-1"></i>Import
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
<i class="bi bi-person-plus me-1"></i>Add User
|
<i class="bi bi-person-plus me-1"></i>Add User
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from flask import flash, redirect, url_for
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
|
||||||
|
def editor_required(f):
|
||||||
|
"""Allow only admin and editor roles. Readonly users are redirected."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
if not current_user.is_editor:
|
||||||
|
flash('You do not have permission to perform this action.', 'danger')
|
||||||
|
return redirect(url_for('dashboard.index'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
"""Allow only admin role."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
if not current_user.is_admin:
|
||||||
|
flash('Administrator access is required.', 'danger')
|
||||||
|
return redirect(url_for('dashboard.index'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
@@ -54,6 +54,9 @@ class Config:
|
|||||||
PORTAL_LOGIN_URL = os.environ.get('PORTAL_LOGIN_URL', 'http://localhost:8080/login')
|
PORTAL_LOGIN_URL = os.environ.get('PORTAL_LOGIN_URL', 'http://localhost:8080/login')
|
||||||
PORTAL_LOGOUT_URL = os.environ.get('PORTAL_LOGOUT_URL', 'http://localhost:8080/logout')
|
PORTAL_LOGOUT_URL = os.environ.get('PORTAL_LOGOUT_URL', 'http://localhost:8080/logout')
|
||||||
|
|
||||||
|
# Internal service-to-service sync secret — must match portal's INTERNAL_SYNC_SECRET
|
||||||
|
INTERNAL_SYNC_SECRET = os.environ.get('INTERNAL_SYNC_SECRET', 'change-this-internal-secret')
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
ITEMS_PER_PAGE = int(os.environ.get('ITEMS_PER_PAGE', 25))
|
ITEMS_PER_PAGE = int(os.environ.get('ITEMS_PER_PAGE', 25))
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ class Config:
|
|||||||
'icon': '💼',
|
'icon': '💼',
|
||||||
'url': '/itassets/',
|
'url': '/itassets/',
|
||||||
'color': '#f59e0b',
|
'color': '#f59e0b',
|
||||||
|
# Internal URL for portal→app user sync (not through nginx)
|
||||||
|
'internal_url': os.environ.get('ITASSETS_INTERNAL_URL', 'http://localhost:5003'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 'srvmonitor',
|
'id': 'srvmonitor',
|
||||||
|
|||||||
Reference in New Issue
Block a user