IT Assets: add portal sync button + wire env vars in start-dev.sh
- Portal: add /api/internal/itassets-users endpoint (returns all portal users with itassets access + their effective role) - IT Assets: add PORTAL_INTERNAL_URL config key - IT Assets: add POST /settings/sync-from-portal route (admin only) — pulls users from the portal API and upserts them locally - Settings page: 'Sync from Portal' button next to the users table header - start-dev.sh: pass INTERNAL_SYNC_SECRET and ITASSETS_INTERNAL_URL to portal; pass PORTAL_INTERNAL_URL and INTERNAL_SYNC_SECRET to itassets - Also includes 'Back to Portal' icon button added to sidebar footer
This commit is contained in:
@@ -3,6 +3,7 @@ from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.portal_sso import _portal_role_to_local
|
||||
|
||||
bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||
|
||||
@@ -104,3 +105,63 @@ def delete_admin(admin_id):
|
||||
db.session.commit()
|
||||
flash(f'User "{username}" deleted.', 'success')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
|
||||
@bp.route('/sync-from-portal', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def sync_from_portal():
|
||||
"""Pull all portal users with itassets access and upsert them locally."""
|
||||
import urllib.request
|
||||
import json as _json
|
||||
|
||||
portal_url = current_app.config.get('PORTAL_INTERNAL_URL', 'http://localhost:5001')
|
||||
secret = current_app.config.get('INTERNAL_SYNC_SECRET', '')
|
||||
url = portal_url.rstrip('/') + '/api/internal/itassets-users'
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'X-Internal-Token': secret},
|
||||
method='GET',
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
users = _json.loads(resp.read())
|
||||
except Exception as exc:
|
||||
flash(f'Could not reach portal: {exc}', 'danger')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
created = updated = 0
|
||||
for u in users:
|
||||
username = u.get('username', '').strip()
|
||||
email = u.get('email', '') or f'{username}@portal.local'
|
||||
local_role = _portal_role_to_local(u.get('app_role', 'standard'))
|
||||
if not username:
|
||||
continue
|
||||
existing = AdminUser.query.filter_by(username=username).first()
|
||||
if existing:
|
||||
changed = False
|
||||
if existing.role != local_role:
|
||||
existing.role = local_role
|
||||
changed = True
|
||||
if existing.email != email:
|
||||
existing.email = email
|
||||
changed = True
|
||||
if changed:
|
||||
updated += 1
|
||||
else:
|
||||
import secrets as _sec
|
||||
new_user = AdminUser(
|
||||
username=username,
|
||||
email=email,
|
||||
full_name=username,
|
||||
role=local_role,
|
||||
is_active=True,
|
||||
)
|
||||
new_user.set_password(_sec.token_hex(32))
|
||||
db.session.add(new_user)
|
||||
created += 1
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Portal sync complete — {created} created, {updated} updated.', 'success')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
@@ -190,7 +190,10 @@
|
||||
<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="/" class="ms-2 text-info text-decoration-none" title="Back to Portal">
|
||||
<i class="bi bi-house-door"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('auth.logout') }}" class="ms-1 text-warning text-decoration-none" title="Sign out">
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,15 @@
|
||||
<!-- App Users -->
|
||||
<div class="col-md-7">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white fw-semibold py-3">
|
||||
<i class="bi bi-person-gear me-2 text-primary"></i>Application Users
|
||||
<div class="card-header bg-white fw-semibold py-3 d-flex align-items-center justify-content-between">
|
||||
<span><i class="bi bi-person-gear me-2 text-primary"></i>Application Users</span>
|
||||
{% if current_user.is_admin %}
|
||||
<form method="POST" action="{{ url_for('settings.sync_from_portal') }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Sync from Portal
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
|
||||
@@ -57,6 +57,9 @@ class Config:
|
||||
# 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')
|
||||
|
||||
# URL to reach the portal directly (bypassing nginx) for internal sync pulls
|
||||
PORTAL_INTERNAL_URL = os.environ.get('PORTAL_INTERNAL_URL', 'http://localhost:5001')
|
||||
|
||||
# Pagination
|
||||
ITEMS_PER_PAGE = int(os.environ.get('ITEMS_PER_PAGE', 25))
|
||||
|
||||
|
||||
@@ -106,3 +106,35 @@ def nv_users_internal():
|
||||
'nv_role': nv_access.app_role or u.role, # per-app override or portal role
|
||||
})
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route('/internal/itassets-users')
|
||||
def itassets_users_internal():
|
||||
"""
|
||||
Internal endpoint for the IT Asset Management app to pull all portal users
|
||||
that have itassets access, with their effective role.
|
||||
Protected by X-Internal-Token header (shared INTERNAL_SYNC_SECRET).
|
||||
"""
|
||||
secret = current_app.config.get('INTERNAL_SYNC_SECRET', '')
|
||||
provided = request.headers.get('X-Internal-Token', '')
|
||||
if not secret or not provided or not _secrets.compare_digest(provided, secret):
|
||||
return jsonify({'error': 'forbidden'}), 403
|
||||
|
||||
from app.models.user import PortalUser
|
||||
from app.models.app_access import AppAccess
|
||||
|
||||
users = PortalUser.query.filter_by(is_active=True).order_by(PortalUser.username).all()
|
||||
result = []
|
||||
for u in users:
|
||||
access = AppAccess.query.filter_by(
|
||||
user_id=u.id, app_name='itassets', is_active=True
|
||||
).first()
|
||||
if access is None:
|
||||
continue
|
||||
result.append({
|
||||
'username': u.username,
|
||||
'email': u.email,
|
||||
'portal_role': u.role,
|
||||
'app_role': access.app_role or u.role,
|
||||
})
|
||||
return jsonify(result)
|
||||
|
||||
@@ -383,6 +383,8 @@ start_bg "portal" \
|
||||
env FLASK_ENV=development \
|
||||
DATA_DIR="$ROOT/portal/data" \
|
||||
PORT=$PORTAL_PORT \
|
||||
INTERNAL_SYNC_SECRET="${INTERNAL_SYNC_SECRET:-change-this-internal-secret}" \
|
||||
ITASSETS_INTERNAL_URL="http://127.0.0.1:${ITASSETS_PORT}" \
|
||||
"$ROOT/portal/.venv/bin/python" "$ROOT/portal/run.py"
|
||||
|
||||
# DigiServer
|
||||
@@ -414,6 +416,8 @@ if module_enabled "itassets"; then
|
||||
PORTAL_JWT_SECRET=change-this-jwt-secret-in-production \
|
||||
PORTAL_LOGIN_URL="http://localhost:${NGINX_PORT}/login" \
|
||||
PORTAL_LOGOUT_URL="http://localhost:${NGINX_PORT}/logout" \
|
||||
PORTAL_INTERNAL_URL="http://127.0.0.1:${PORTAL_PORT}" \
|
||||
INTERNAL_SYNC_SECRET="${INTERNAL_SYNC_SECRET:-change-this-internal-secret}" \
|
||||
FLASK_APP=run.py \
|
||||
"$ROOT/IT_asset_management/.venv/bin/python" "$ROOT/IT_asset_management/run.py"
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user